Skip to content

Commit 51364b4

Browse files
Token Management CLI Commands (#77)
Add complete token lifecycle management with commands for creation, listing, and revocation: - create: Generate auth tokens with custom expiry (supports day notation like "30d") and optional descriptions - create-setup: Generate short-lived setup codes for secure token distribution - list: Display tokens with filtering by name prefix and expired token inclusion - revoke: Invalidate tokens by UUID with confirmation prompts Extend time utilities with ParseDuration for day notation support and FormatRelativeTime for human-readable timestamps with appropriate granularity (minutes/hours/days). Co-authored-by: construct-agent <noreply@construct.sh>
1 parent b353df9 commit 51364b4

8 files changed

Lines changed: 496 additions & 0 deletions

File tree

frontend/cli/cmd/daemon.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ func NewDaemonCmd() *cobra.Command {
1515
cmd.AddCommand(NewDaemonInstallCmd())
1616
cmd.AddCommand(NewDaemonUninstallCmd())
1717
cmd.AddCommand(NewDaemonStopCmd())
18+
cmd.AddCommand(NewDaemonTokenCmd())
1819
return cmd
1920
}

frontend/cli/cmd/daemon_token.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cmd
2+
3+
import (
4+
v1 "github.com/furisto/construct/api/go/v1"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func NewDaemonTokenCmd() *cobra.Command {
9+
cmd := &cobra.Command{
10+
Use: "token",
11+
Short: "Manage authentication tokens",
12+
Long: `Manage authentication tokens for remote daemon access.
13+
14+
Tokens provide secure authentication for connecting to remote Construct daemons
15+
over HTTPS. Each token has a name, optional description, and expiration time.
16+
17+
Token management requires admin privileges (local Unix socket connection).`,
18+
}
19+
20+
cmd.AddCommand(NewDaemonTokenCreateCmd())
21+
cmd.AddCommand(NewDaemonTokenCreateSetupCmd())
22+
cmd.AddCommand(NewDaemonTokenListCmd())
23+
cmd.AddCommand(NewDaemonTokenRevokeCmd())
24+
25+
return cmd
26+
}
27+
28+
type TokenDisplay struct {
29+
ID string `json:"id" detail:"default"`
30+
Name string `json:"name" detail:"default"`
31+
Created string `json:"created" detail:"default"`
32+
Expires string `json:"expires" detail:"default"`
33+
Status string `json:"status" detail:"default"`
34+
}
35+
36+
func ConvertTokenInfoToDisplay(token *v1.TokenInfo) *TokenDisplay {
37+
status := "Active"
38+
if !token.IsActive {
39+
status = "Expired"
40+
}
41+
42+
return &TokenDisplay{
43+
ID: token.Id,
44+
Name: token.Name,
45+
Created: FormatRelativeTime(token.CreatedAt.AsTime()),
46+
Expires: FormatRelativeTime(token.ExpiresAt.AsTime()),
47+
Status: status,
48+
}
49+
}
50+
51+
type TokenCreateDisplay struct {
52+
Name string `json:"name" yaml:"name"`
53+
Token string `json:"token" yaml:"token"`
54+
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
55+
}
56+
57+
type SetupCodeDisplay struct {
58+
TokenName string `json:"token_name" yaml:"token_name"`
59+
SetupCode string `json:"setup_code" yaml:"setup_code"`
60+
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
61+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"time"
7+
8+
"connectrpc.com/connect"
9+
v1 "github.com/furisto/construct/api/go/v1"
10+
"github.com/spf13/cobra"
11+
"google.golang.org/protobuf/types/known/durationpb"
12+
)
13+
14+
type tokenCreateOptions struct {
15+
Description string
16+
Expires string
17+
RenderOptions RenderOptions
18+
}
19+
20+
func NewDaemonTokenCreateCmd() *cobra.Command {
21+
var options tokenCreateOptions
22+
23+
cmd := &cobra.Command{
24+
Use: "create <name> [flags]",
25+
Short: "Generate a new API token for remote daemon authentication",
26+
Args: cobra.ExactArgs(1),
27+
Long: `Generate a new API token for remote daemon authentication.
28+
29+
The token is displayed once and cannot be retrieved again. Store it securely
30+
in a password manager or system keyring.
31+
32+
Tokens are used to authenticate CLI commands against remote daemon instances
33+
over HTTPS. Configure a context with the token using 'construct context add'.`,
34+
Example: ` # Create token with default 90-day expiry
35+
construct daemon token create laptop-token
36+
37+
# Create token with custom expiry and description
38+
construct daemon token create ci-pipeline \
39+
--description "GitHub Actions pipeline token" \
40+
--expires 30d
41+
42+
# Create token with JSON output for scripting
43+
construct daemon token create automation --output json`,
44+
RunE: func(cmd *cobra.Command, args []string) error {
45+
name := args[0]
46+
47+
expiresDuration, err := ParseDuration(options.Expires)
48+
if err != nil {
49+
return fmt.Errorf("invalid expiry duration: %w", err)
50+
}
51+
52+
if err := ValidateTokenExpiry(expiresDuration); err != nil {
53+
return err
54+
}
55+
56+
client := getAPIClient(cmd.Context())
57+
58+
req := &connect.Request[v1.CreateTokenRequest]{
59+
Msg: &v1.CreateTokenRequest{
60+
Name: name,
61+
ExpiresIn: durationpb.New(expiresDuration),
62+
},
63+
}
64+
65+
if options.Description != "" {
66+
req.Msg.Description = &options.Description
67+
}
68+
69+
resp, err := client.Auth().CreateToken(cmd.Context(), req)
70+
if err != nil {
71+
return fmt.Errorf("failed to create token: %w", err)
72+
}
73+
74+
display := &TokenCreateDisplay{
75+
Name: name,
76+
Token: resp.Msg.Token,
77+
ExpiresAt: resp.Msg.ExpiresAt.AsTime().Format(time.RFC3339),
78+
}
79+
80+
if err := getRenderer(cmd.Context()).Render(display, &options.RenderOptions); err != nil {
81+
return err
82+
}
83+
84+
if options.RenderOptions.Format == OutputFormatCard || options.RenderOptions.Format == "" {
85+
fmt.Fprintln(os.Stderr, "")
86+
fmt.Fprintln(os.Stderr, "⚠️ Save this token securely - it cannot be retrieved again.")
87+
}
88+
89+
return nil
90+
},
91+
}
92+
93+
cmd.Flags().StringVar(&options.Description, "description", "", "Optional description of token purpose")
94+
cmd.Flags().StringVar(&options.Expires, "expires", "90d", "Token lifetime (default: 90d, max: 365d)")
95+
addRenderOptions(cmd, &options.RenderOptions)
96+
WithCardFormat(&options.RenderOptions)
97+
98+
return cmd
99+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"time"
7+
8+
"connectrpc.com/connect"
9+
v1 "github.com/furisto/construct/api/go/v1"
10+
"github.com/spf13/cobra"
11+
"google.golang.org/protobuf/types/known/durationpb"
12+
)
13+
14+
type tokenCreateSetupOptions struct {
15+
CodeExpires string
16+
TokenExpires string
17+
RenderOptions RenderOptions
18+
}
19+
20+
func NewDaemonTokenCreateSetupCmd() *cobra.Command {
21+
var options tokenCreateSetupOptions
22+
23+
cmd := &cobra.Command{
24+
Use: "create-setup <token-name> [flags]",
25+
Short: "Generate a short-lived setup code for secure token distribution",
26+
Args: cobra.ExactArgs(1),
27+
Long: `Generate a short-lived setup code for secure token distribution.
28+
29+
Setup codes provide a secure way to distribute tokens to remote clients without
30+
sending the token itself over insecure channels. The code expires quickly and
31+
can only be used once.
32+
33+
The client exchanges the setup code for a token using:
34+
construct context add <name> --endpoint <url> --setup-code <code>`,
35+
Example: ` # Create setup code with default settings
36+
construct daemon token create-setup remote-laptop
37+
38+
# Create setup code with custom expiry times
39+
construct daemon token create-setup staging-server \
40+
--code-expires 1h \
41+
--token-expires 30d
42+
43+
# Create setup code with JSON output
44+
construct daemon token create-setup prod-api --output json`,
45+
RunE: func(cmd *cobra.Command, args []string) error {
46+
tokenName := args[0]
47+
48+
codeExpiresDuration, err := ParseDuration(options.CodeExpires)
49+
if err != nil {
50+
return fmt.Errorf("invalid code expiry duration: %w", err)
51+
}
52+
53+
if err := ValidateSetupCodeExpiry(codeExpiresDuration); err != nil {
54+
return err
55+
}
56+
57+
tokenExpiresDuration, err := ParseDuration(options.TokenExpires)
58+
if err != nil {
59+
return fmt.Errorf("invalid token expiry duration: %w", err)
60+
}
61+
62+
if err := ValidateTokenExpiry(tokenExpiresDuration); err != nil {
63+
return err
64+
}
65+
66+
client := getAPIClient(cmd.Context())
67+
68+
req := &connect.Request[v1.CreateSetupCodeRequest]{
69+
Msg: &v1.CreateSetupCodeRequest{
70+
TokenName: tokenName,
71+
ExpiresIn: durationpb.New(codeExpiresDuration),
72+
TokenExpiresIn: durationpb.New(tokenExpiresDuration),
73+
},
74+
}
75+
76+
resp, err := client.Auth().CreateSetupCode(cmd.Context(), req)
77+
if err != nil {
78+
return fmt.Errorf("failed to create setup code: %w", err)
79+
}
80+
81+
display := &SetupCodeDisplay{
82+
TokenName: tokenName,
83+
SetupCode: resp.Msg.SetupCode,
84+
ExpiresAt: resp.Msg.ExpiresAt.AsTime().Format(time.RFC3339),
85+
}
86+
87+
if err := getRenderer(cmd.Context()).Render(display, &options.RenderOptions); err != nil {
88+
return err
89+
}
90+
91+
if options.RenderOptions.Format == OutputFormatCard || options.RenderOptions.Format == "" {
92+
fmt.Fprintln(os.Stderr, "")
93+
fmt.Fprintln(os.Stderr, "Share this code securely with the user. They can exchange it for a token using:")
94+
fmt.Fprintln(os.Stderr, "")
95+
fmt.Fprintf(os.Stderr, " construct context add <name> \\\n")
96+
fmt.Fprintf(os.Stderr, " --endpoint <daemon-url> \\\n")
97+
fmt.Fprintf(os.Stderr, " --setup-code %s\n", resp.Msg.SetupCode)
98+
fmt.Fprintln(os.Stderr, "")
99+
fmt.Fprintln(os.Stderr, "⚠️ This code can only be used once and expires in", FormatRelativeTime(resp.Msg.ExpiresAt.AsTime()))
100+
}
101+
102+
return nil
103+
},
104+
}
105+
106+
cmd.Flags().StringVar(&options.CodeExpires, "code-expires", "5m", "Setup code lifetime (default: 5m, max: 72h)")
107+
cmd.Flags().StringVar(&options.TokenExpires, "token-expires", "90d", "Resulting token lifetime (default: 90d, max: 365d)")
108+
addRenderOptions(cmd, &options.RenderOptions)
109+
WithCardFormat(&options.RenderOptions)
110+
111+
return cmd
112+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"connectrpc.com/connect"
7+
v1 "github.com/furisto/construct/api/go/v1"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
type tokenListOptions struct {
12+
NamePrefix string
13+
IncludeExpired bool
14+
RenderOptions RenderOptions
15+
}
16+
17+
func NewDaemonTokenListCmd() *cobra.Command {
18+
var options tokenListOptions
19+
20+
cmd := &cobra.Command{
21+
Use: "list [flags]",
22+
Short: "List all tokens with metadata",
23+
Aliases: []string{"ls"},
24+
Long: `List all tokens with metadata.
25+
26+
Displays token ID, name, creation time, expiration time, and status. Token
27+
values are never shown - only metadata. Use filters to narrow results.`,
28+
Example: ` # List all active tokens
29+
construct daemon token list
30+
31+
# Filter by name prefix
32+
construct daemon token list --name-prefix prod
33+
34+
# Include expired tokens
35+
construct daemon token list --include-expired
36+
37+
# JSON output for scripting
38+
construct daemon token ls --output json`,
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
client := getAPIClient(cmd.Context())
41+
42+
req := &connect.Request[v1.ListTokensRequest]{
43+
Msg: &v1.ListTokensRequest{
44+
IncludeExpired: options.IncludeExpired,
45+
},
46+
}
47+
48+
if options.NamePrefix != "" {
49+
req.Msg.NamePrefix = options.NamePrefix
50+
}
51+
52+
resp, err := client.Auth().ListTokens(cmd.Context(), req)
53+
if err != nil {
54+
return fmt.Errorf("failed to list tokens: %w", err)
55+
}
56+
57+
displayTokens := make([]*TokenDisplay, len(resp.Msg.Tokens))
58+
for i, token := range resp.Msg.Tokens {
59+
displayTokens[i] = ConvertTokenInfoToDisplay(token)
60+
}
61+
62+
return getRenderer(cmd.Context()).Render(displayTokens, &options.RenderOptions)
63+
},
64+
}
65+
66+
cmd.Flags().StringVar(&options.NamePrefix, "name-prefix", "", "Filter by name prefix")
67+
cmd.Flags().BoolVar(&options.IncludeExpired, "include-expired", false, "Include expired tokens in results")
68+
addRenderOptions(cmd, &options.RenderOptions)
69+
70+
return cmd
71+
}

0 commit comments

Comments
 (0)