Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/cmd/cli/command/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ func SetupCommands(version string) {
})
RootCmd.AddCommand(tokenCmd)

// Identity commands
identityRegisterCmd.Flags().Duration("ttl", 0, "expiry of the registered key (default: no expiry)")
identityCmd.AddCommand(identityRegisterCmd)
identityCmd.AddCommand(identityListCmd)
identityCmd.AddCommand(identityRevokeCmd)
RootCmd.AddCommand(identityCmd)

// Login Command
loginCmd.Flags().Bool("training-opt-out", false, "Opt out of ML training (Pro users only)")
// loginCmd.Flags().Bool("skip-prompt", false, "skip the login prompt if already logged in"); TODO: Implement this
Expand Down
59 changes: 59 additions & 0 deletions src/cmd/cli/command/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package command

import (
"github.com/DefangLabs/defang/src/pkg/cli"
"github.com/DefangLabs/defang/src/pkg/cli/client"
"github.com/spf13/cobra"
)

var identityCmd = &cobra.Command{
Use: "identity",
Args: cobra.NoArgs,
Short: "Manage agent identity keys (public-key registration for cloud federation)",
}

var identityRegisterCmd = &cobra.Command{
Use: "register",
Annotations: authNeededAlways,
Args: cobra.NoArgs,
Short: "Register this machine's public key for the current project and stack",
RunE: func(cmd *cobra.Command, args []string) error {
ttl, _ := cmd.Flags().GetDuration("ttl")

// No CheckAccountInfo: registration talks to the key registry, not the cloud provider.
session, err := newCommandSessionWithOpts(cmd, commandSessionOpts{})
if err != nil {
return err
}
projectName, err := client.LoadProjectNameWithFallback(cmd.Context(), session.Loader, session.Provider)
if err != nil {
return err
}

accessToken := client.GetExistingToken(global.FabricAddr)
return cli.IdentityRegister(cmd.Context(), global.Client, accessToken, projectName, session.Stack.Name, ttl)
},
}

var identityListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Annotations: authNeededAlways,
Args: cobra.NoArgs,
Short: "List your registered agent identity keys",
RunE: func(cmd *cobra.Command, args []string) error {
accessToken := client.GetExistingToken(global.FabricAddr)
return cli.IdentityList(cmd.Context(), global.Client, accessToken)
},
}

var identityRevokeCmd = &cobra.Command{
Use: "revoke KID",
Annotations: authNeededAlways,
Args: cobra.ExactArgs(1),
Short: "Revoke a registered agent identity key",
RunE: func(cmd *cobra.Command, args []string) error {
accessToken := client.GetExistingToken(global.FabricAddr)
return cli.IdentityRevoke(cmd.Context(), global.Client, accessToken, args[0])
},
}
2 changes: 1 addition & 1 deletion src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ require (
github.com/digitalocean/godo v1.131.1
github.com/docker/cli v29.2.1+incompatible
github.com/firebase/genkit/go v1.2.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/googleapis/gax-go/v2 v2.14.2
Expand Down Expand Up @@ -115,7 +116,6 @@ require (
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect
Expand Down
4 changes: 4 additions & 0 deletions src/pkg/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ func NewClient(clientID, issuer string) *client {
}
}

func (c client) Issuer() string {
return c.issuer
}

func (c client) GetPollRedirectURI() string {
return c.issuer + "/clients/auth"
}
Expand Down
115 changes: 115 additions & 0 deletions src/pkg/cli/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package cli

import (
"context"
"fmt"
"path/filepath"
"time"

"github.com/DefangLabs/defang/src/pkg/auth"
"github.com/DefangLabs/defang/src/pkg/cli/client"
"github.com/DefangLabs/defang/src/pkg/dryrun"
"github.com/DefangLabs/defang/src/pkg/identity"
"github.com/DefangLabs/defang/src/pkg/term"
)

// identityRegistryClient builds a registry client for the fabric client's
// tenant, reusing the OpenAuth access token saved by `defang login`.
func identityRegistryClient(fabricClient client.FabricClient, accessToken string) (*identity.Client, error) {
tenantURL, err := identity.TenantURL(auth.OpenAuthClient.Issuer(), string(fabricClient.GetTenantName()))
if err != nil {
return nil, err
}
return identity.NewClient(tenantURL, accessToken), nil
}

// IdentityKeyDir returns where the private key for a (tenant, project, stack)
// lives: one key per pair, because the registry rejects key reuse across
// stacks. The private key never leaves this directory.
func IdentityKeyDir(fabricClient client.FabricClient, projectName, stackName string) string {
return filepath.Join(client.StateDir, "identity", string(fabricClient.GetTenantName()), projectName, stackName)
}

func IdentityRegister(ctx context.Context, fabricClient client.FabricClient, accessToken, projectName, stackName string, ttl time.Duration) error {
if dryrun.DoDryRun {
return dryrun.ErrDryRun
}

registry, err := identityRegistryClient(fabricClient, accessToken)
if err != nil {
return err
}

keyDir := IdentityKeyDir(fabricClient, projectName, stackName)
key, err := identity.LoadOrGenerateKey(keyDir)
if err != nil {
return fmt.Errorf("failed to load or generate keypair: %w", err)
}
term.Debugf("Using keypair in %s", keyDir)

popJwt, err := key.PopJWT(time.Now())
if err != nil {
return fmt.Errorf("failed to sign proof-of-possession: %w", err)
}

registered, err := registry.Register(ctx, identity.RegisterRequest{
ProjectID: projectName,
StackID: stackName,
JWK: key.PublicJWK(),
PopJWT: popJwt,
TTLSeconds: int(ttl.Seconds()),
})
if err != nil {
return err
}

term.Printc(term.BrightCyan, "Registered public key: ")
term.Println(registered.Kid)
term.Info("Subject:", registered.Subject)
if registered.Issuer != "" {
term.Info("Issuer:", registered.Issuer)
}
if registered.Expires > 0 {
term.Info("Expires:", time.Unix(registered.Expires, 0).UTC().Format(time.RFC3339))
}
return nil
}

func IdentityList(ctx context.Context, fabricClient client.FabricClient, accessToken string) error {
registry, err := identityRegistryClient(fabricClient, accessToken)
if err != nil {
return err
}
keys, err := registry.List(ctx)
if err != nil {
return err
}
if len(keys) == 0 {
term.Info("No keys registered")
return nil
}
for _, key := range keys {
expires := ""
if key.Expires > 0 {
expires = " expires " + time.Unix(key.Expires, 0).UTC().Format(time.RFC3339)
}
term.Printf("%s project %q stack %q%s", key.Kid, key.ProjectID, key.StackID, expires)
}
return nil
}

func IdentityRevoke(ctx context.Context, fabricClient client.FabricClient, accessToken, kid string) error {
if dryrun.DoDryRun {
return dryrun.ErrDryRun
}

registry, err := identityRegistryClient(fabricClient, accessToken)
if err != nil {
return err
}
if err := registry.Revoke(ctx, kid); err != nil {
return err
}
term.Info("Revoked key", kid)
return nil
}
19 changes: 19 additions & 0 deletions src/pkg/http/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package http

import (
"context"
"net/http"
)

func DeleteWithHeader(ctx context.Context, url string, header http.Header) (*http.Response, error) {
hreq, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return nil, err
}
hreq.Header = header
return DefaultClient.Do(hreq)
}

func DeleteWithAuth(ctx context.Context, url, auth string) (*http.Response, error) {
return DeleteWithHeader(ctx, url, http.Header{"Authorization": []string{auth}})
}
133 changes: 133 additions & 0 deletions src/pkg/identity/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package identity

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"

defangHttp "github.com/DefangLabs/defang/src/pkg/http"
"github.com/go-jose/go-jose/v4"
)

// TenantURL derives the tenant's issuer URL from the apex issuer, e.g.
// https://auth.defang.io + "acme" → https://acme.auth.defang.io. The
// subdomain label is the tenant, per the agent-identity design.
func TenantURL(issuer, tenant string) (string, error) {
if tenant == "" {
return "", errors.New("no tenant selected; log in or set DEFANG_WORKSPACE")
}
u, err := url.Parse(issuer)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("invalid issuer URL %q", issuer)
}
u.Host = tenant + "." + u.Host
u.Path = ""
return u.String(), nil
}

// Client talks to one tenant's key registry with a bearer token from the
// OpenAuth issuer (the token saved by `defang login`).
type Client struct {
tenantURL string
accessToken string
}

func NewClient(tenantURL, accessToken string) *Client {
return &Client{tenantURL: strings.TrimSuffix(tenantURL, "/"), accessToken: accessToken}
}

type RegisterRequest struct {
ProjectID string `json:"project_id"`
StackID string `json:"stack_id"`
JWK jose.JSONWebKey `json:"jwk"`
PopJWT string `json:"pop_jwt"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
}

// RegisteredKey is a key record as returned by the registry; POST /keys
// returns kid/sub/issuer/exp, GET /keys additionally has project/stack/created.
type RegisteredKey struct {
Kid string `json:"kid"`
Subject string `json:"sub"`
Issuer string `json:"issuer,omitempty"`
ProjectID string `json:"project_id,omitempty"`
StackID string `json:"stack_id,omitempty"`
Created int64 `json:"created,omitempty"`
Expires int64 `json:"exp,omitempty"`
}

func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisteredKey, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
header := http.Header{
"Authorization": []string{"Bearer " + c.accessToken},
"Content-Type": []string{"application/json"},
}
resp, err := defangHttp.PostWithHeader(ctx, c.tenantURL+"/keys", header, bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var registered RegisteredKey
if err := decodeResponse(resp, &registered); err != nil {
return nil, err
}
return &registered, nil
}

func (c *Client) List(ctx context.Context) ([]RegisteredKey, error) {
resp, err := defangHttp.GetWithAuth(ctx, c.tenantURL+"/keys", "Bearer "+c.accessToken)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var list struct {
Keys []RegisteredKey `json:"keys"`
}
if err := decodeResponse(resp, &list); err != nil {
return nil, err
}
return list.Keys, nil
}

func (c *Client) Revoke(ctx context.Context, kid string) error {
resp, err := defangHttp.DeleteWithAuth(ctx, c.tenantURL+"/keys/"+url.PathEscape(kid), "Bearer "+c.accessToken)
if err != nil {
return err
}
defer resp.Body.Close()
return decodeResponse(resp, nil)
}

// decodeResponse decodes a 2xx JSON body into out (if non-nil), or surfaces
// the registry's {"error": …} message on failure.
func decodeResponse(resp *http.Response, out any) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var oauthError struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &oauthError) == nil && oauthError.Error != "" {
return fmt.Errorf("key registry: %s (%s)", oauthError.Error, resp.Status)
}
return fmt.Errorf("key registry: unexpected status %s", resp.Status)
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("key registry: invalid response: %w", err)
}
return nil
}
Loading