diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 2de481418..c28984b58 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -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 diff --git a/src/cmd/cli/command/identity.go b/src/cmd/cli/command/identity.go new file mode 100644 index 000000000..4b717c6b2 --- /dev/null +++ b/src/cmd/cli/command/identity.go @@ -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]) + }, +} diff --git a/src/go.mod b/src/go.mod index 3753b7863..7fad6fe3e 100644 --- a/src/go.mod +++ b/src/go.mod @@ -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 @@ -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 diff --git a/src/pkg/auth/client.go b/src/pkg/auth/client.go index 3c28ce956..bf5bb9271 100644 --- a/src/pkg/auth/client.go +++ b/src/pkg/auth/client.go @@ -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" } diff --git a/src/pkg/cli/identity.go b/src/pkg/cli/identity.go new file mode 100644 index 000000000..5aea0217d --- /dev/null +++ b/src/pkg/cli/identity.go @@ -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 +} diff --git a/src/pkg/http/delete.go b/src/pkg/http/delete.go new file mode 100644 index 000000000..48d487630 --- /dev/null +++ b/src/pkg/http/delete.go @@ -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}}) +} diff --git a/src/pkg/identity/client.go b/src/pkg/identity/client.go new file mode 100644 index 000000000..5f0e695eb --- /dev/null +++ b/src/pkg/identity/client.go @@ -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, ®istered); err != nil { + return nil, err + } + return ®istered, 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 +} diff --git a/src/pkg/identity/client_test.go b/src/pkg/identity/client_test.go new file mode 100644 index 000000000..672e09b6a --- /dev/null +++ b/src/pkg/identity/client_test.go @@ -0,0 +1,158 @@ +package identity + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestTenantURL(t *testing.T) { + tests := []struct { + name string + issuer string + tenant string + want string + wantErr bool + }{ + {"basic", "https://auth.defang.io", "acme", "https://acme.auth.defang.io", false}, + {"strips path", "https://auth.defang.io/base", "acme", "https://acme.auth.defang.io", false}, + {"empty tenant", "https://auth.defang.io", "", "", true}, + {"no scheme", "auth.defang.io", "acme", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := TenantURL(tt.issuer, tt.tenant) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("TenantURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClientRegister(t *testing.T) { + key, err := LoadOrGenerateKey(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/keys" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer test-token" { + t.Errorf("Authorization = %q", auth) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("body: %v", err) + } + json.NewEncoder(w).Encode(map[string]any{ + "kid": "thumb123", + "sub": "defang:project:app:stack:prod", + "issuer": "https://acme.auth.defang.io", + }) + })) + defer server.Close() + + registryClient := NewClient(server.URL, "test-token") + popJwt, err := key.PopJWT(time.Now()) + if err != nil { + t.Fatal(err) + } + registered, err := registryClient.Register(context.Background(), RegisterRequest{ + ProjectID: "app", + StackID: "prod", + JWK: key.PublicJWK(), + PopJWT: popJwt, + }) + if err != nil { + t.Fatal(err) + } + if registered.Kid != "thumb123" || registered.Subject != "defang:project:app:stack:prod" { + t.Errorf("unexpected response: %+v", registered) + } + + if gotBody["project_id"] != "app" || gotBody["stack_id"] != "prod" { + t.Errorf("request body project/stack = %v/%v", gotBody["project_id"], gotBody["stack_id"]) + } + if _, hasTtl := gotBody["ttl_seconds"]; hasTtl { + t.Error("ttl_seconds should be omitted when zero") + } + jwk, _ := gotBody["jwk"].(map[string]any) + if jwk["kty"] != "RSA" { + t.Errorf("jwk = %v", gotBody["jwk"]) + } + if gotBody["pop_jwt"] == "" { + t.Error("missing pop_jwt") + } +} + +func TestClientErrors(t *testing.T) { + tests := []struct { + name string + status int + body string + wantErr string + }{ + {"conflict with message", http.StatusConflict, `{"error":"key already registered for project \"other\""}`, "key already registered"}, + // 400, not 5xx: the retrying HTTP client would replay a 5xx for ~15s + {"plain error body", http.StatusBadRequest, "boom", "unexpected status"}, + {"unauthorized", http.StatusUnauthorized, `{"error":"invalid access token"}`, "invalid access token"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + _, err := NewClient(server.URL, "token").List(context.Background()) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestClientListAndRevoke(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /keys": + json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + {"kid": "k1", "sub": "defang:project:app:stack:prod", "project_id": "app", "stack_id": "prod", "created": 1700000000}, + }, + }) + case "DELETE /keys/k1": + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + registryClient := NewClient(server.URL, "token") + + keys, err := registryClient.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].Kid != "k1" || keys[0].ProjectID != "app" { + t.Errorf("keys = %+v", keys) + } + + if err := registryClient.Revoke(context.Background(), "k1"); err != nil { + t.Errorf("revoke: %v", err) + } + if err := registryClient.Revoke(context.Background(), "missing"); err == nil { + t.Error("expected error revoking unknown kid") + } +} diff --git a/src/pkg/identity/keys.go b/src/pkg/identity/keys.go new file mode 100644 index 000000000..59bc57c13 --- /dev/null +++ b/src/pkg/identity/keys.go @@ -0,0 +1,110 @@ +// Package identity implements the client side of the Defang agent-identity +// key registry: local keypair management, proof-of-possession signing, and +// the HTTP client for the per-tenant OIDC key registry (/keys endpoints). +package identity + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + _ "crypto/sha256" // registered for JSONWebKey.Thumbprint + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" +) + +// RS256 because AWS OIDC federation does not accept Ed25519, making RSA the +// portable choice across cloud identity providers. +const rsaKeyBits = 2048 + +const privateKeyFile = "private.pem" + +// Key is a locally-held agent keypair. Only the public half is ever sent to +// the registry. +type Key struct { + private *rsa.PrivateKey +} + +// LoadOrGenerateKey returns the keypair stored in dir, generating and +// persisting a new one if none exists yet. +func LoadOrGenerateKey(dir string) (*Key, error) { + path := filepath.Join(dir, privateKeyFile) + if pemBytes, err := os.ReadFile(path); err == nil { + return parsePrivateKey(pemBytes, path) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + private, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + if err != nil { + return nil, err + } + der, err := x509.MarshalPKCS8PrivateKey(private) + if err != nil { + return nil, err + } + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(path, pemBytes, 0600); err != nil { + return nil, err + } + return &Key{private: private}, nil +} + +func parsePrivateKey(pemBytes []byte, path string) (*Key, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, fmt.Errorf("no PEM block found in %s", path) + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key %s: %w", path, err) + } + private, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("%s: expected an RSA private key, got %T", path, parsed) + } + return &Key{private: private}, nil +} + +// PublicJWK returns the minimal public JWK (kty/n/e). The registry derives +// kid, alg, and use server-side; sending them would only invite mismatches. +func (k *Key) PublicJWK() jose.JSONWebKey { + return jose.JSONWebKey{Key: k.private.Public()} +} + +// Thumbprint returns the RFC 7638 JWK thumbprint (base64url), which the +// registry also uses as the JWKS kid. +func (k *Key) Thumbprint() (string, error) { + jwk := k.PublicJWK() + thumb, err := jwk.Thumbprint(crypto.SHA256) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(thumb), nil +} + +// PopJWT signs the proof-of-possession token required by the registry: a +// fresh JWT binding the registration request to the private key, so a stolen +// public JWK can't be registered by someone who doesn't hold the private key. +func (k *Key) PopJWT(now time.Time) (string, error) { + thumb, err := k.Thumbprint() + if err != nil { + return "", err + } + claims := jwt.MapClaims{ + "iat": now.Unix(), + "jwk_thumb": thumb, + } + return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(k.private) +} diff --git a/src/pkg/identity/keys_test.go b/src/pkg/identity/keys_test.go new file mode 100644 index 000000000..a723a644f --- /dev/null +++ b/src/pkg/identity/keys_test.go @@ -0,0 +1,127 @@ +package identity + +import ( + "crypto/rsa" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestLoadOrGenerateKey(t *testing.T) { + dir := filepath.Join(t.TempDir(), "keys") + + generated, err := LoadOrGenerateKey(dir) + if err != nil { + t.Fatalf("generate: %v", err) + } + info, err := os.Stat(filepath.Join(dir, "private.pem")) + if err != nil { + t.Fatalf("stat private.pem: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("private.pem permissions = %o, want 0600", perm) + } + + loaded, err := LoadOrGenerateKey(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + + generatedThumb, err := generated.Thumbprint() + if err != nil { + t.Fatalf("thumbprint: %v", err) + } + loadedThumb, err := loaded.Thumbprint() + if err != nil { + t.Fatalf("thumbprint: %v", err) + } + if generatedThumb != loadedThumb { + t.Errorf("loaded key thumbprint %q != generated %q", loadedThumb, generatedThumb) + } +} + +func TestLoadOrGenerateKeyInvalidPem(t *testing.T) { + tests := []struct { + name string + pem string + }{ + {"not pem", "hello"}, + {"wrong key type", "-----BEGIN EC PRIVATE KEY-----\nAAAA\n-----END EC PRIVATE KEY-----\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "private.pem"), []byte(tt.pem), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadOrGenerateKey(dir); err == nil { + t.Error("expected error for invalid private.pem, got nil") + } + }) + } +} + +func TestPublicJWKIsMinimalRSA(t *testing.T) { + key, err := LoadOrGenerateKey(t.TempDir()) + if err != nil { + t.Fatal(err) + } + bytes, err := json.Marshal(key.PublicJWK()) + if err != nil { + t.Fatal(err) + } + var jwk map[string]any + if err := json.Unmarshal(bytes, &jwk); err != nil { + t.Fatal(err) + } + if jwk["kty"] != "RSA" { + t.Errorf("kty = %v, want RSA", jwk["kty"]) + } + for _, private := range []string{"d", "p", "q", "dp", "dq", "qi"} { + if _, leaked := jwk[private]; leaked { + t.Errorf("public JWK leaks private field %q", private) + } + } +} + +func TestPopJWT(t *testing.T) { + key, err := LoadOrGenerateKey(t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.Now() + signed, err := key.PopJWT(now) + if err != nil { + t.Fatal(err) + } + + parsed, err := jwt.Parse(signed, func(token *jwt.Token) (any, error) { + public, ok := key.private.Public().(*rsa.PublicKey) + if !ok { + t.Fatal("not an RSA key") + } + return public, nil + }, jwt.WithValidMethods([]string{"RS256"})) + if err != nil { + t.Fatalf("PoP JWT does not verify with its own public key: %v", err) + } + + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + t.Fatalf("claims are %T, not MapClaims", parsed.Claims) + } + thumb, err := key.Thumbprint() + if err != nil { + t.Fatal(err) + } + if claims["jwk_thumb"] != thumb { + t.Errorf("jwk_thumb = %v, want %v", claims["jwk_thumb"], thumb) + } + if iat, _ := claims.GetIssuedAt(); iat == nil || iat.Unix() != now.Unix() { + t.Errorf("iat = %v, want %v", iat, now.Unix()) + } +}