diff --git a/cmd/generate/sdk.go b/cmd/generate/sdk.go index 90228d1eb..a6ab3d767 100644 --- a/cmd/generate/sdk.go +++ b/cmd/generate/sdk.go @@ -31,11 +31,12 @@ type GenerateFlags struct { } var genSDKCmd = &model.ExecutableCommand[GenerateFlags]{ - Usage: "sdk", - Short: fmt.Sprintf("One-off SDK generation from OpenAPI specs (%s)", strings.Join(GeneratorSupportedTargetNames(), ", ")), - Long: generateLongDesc, - Run: genSDKs, - RequiresAuth: true, + Usage: "sdk", + Short: fmt.Sprintf("One-off SDK generation from OpenAPI specs (%s)", strings.Join(GeneratorSupportedTargetNames(), ", ")), + Long: generateLongDesc, + Run: genSDKs, + RequiresAuth: true, + OfflineCapable: true, Flags: []flag.Flag{ flag.EnumFlag{ Name: "lang", diff --git a/cmd/lint/lint.go b/cmd/lint/lint.go index 8db3f2c29..27eeaa7c1 100644 --- a/cmd/lint/lint.go +++ b/cmd/lint/lint.go @@ -11,6 +11,8 @@ import ( generationaccess "github.com/speakeasy-api/generation-context/access" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" + "github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken" + coreauth "github.com/speakeasy-api/speakeasy-core/auth" "github.com/speakeasy-api/speakeasy-core/openapi" "github.com/speakeasy-api/speakeasy-core/suggestions" "github.com/speakeasy-api/speakeasy/internal/arazzo" @@ -589,10 +591,18 @@ func warningsToTabContents(warnings []error) []interactivity.InspectableContent // runDryRunGeneration runs a dry-run SDK generation for the specified target and returns warnings func runDryRunGeneration(ctx context.Context, schemaPath, targetLanguage, workingDir string) ([]error, error) { - // Lint is available without authentication, so its optional diagnostic - // generation must explicitly use the direct AGPL mode when no caller state exists. + // The CLI only generates under the customer's commercial license (the AGPL + // election is a source-build fallback in the upstream generator), so the + // diagnostic dry-run elects commercial with the workspace license token. + // Lint runs without authentication too; in that case there is nothing to + // elect, so the optional dry-run diagnostics are skipped. if _, ok := generationaccess.StateFromContext(ctx); !ok { - ctx = generationaccess.WithDirect(ctx) + licenseToken, _ := coreauth.GetLicenseTokenFromContext(ctx) + commercialCtx, err := coreauth.WithGenerationContext(ctx, generationaccess.GeneratedLicenseCommercial) + if err != nil { + return nil, nil + } + ctx = licensetoken.WithToken(commercialCtx, licenseToken) } // Load the OpenAPI schema diff --git a/cmd/run.go b/cmd/run.go index cf1f8aba9..1cda803ce 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -78,6 +78,7 @@ var runCmd = &model.ExecutableCommand[RunFlags]{ Run: runNonInteractive, RunInteractive: runInteractive, RequiresAuth: true, + OfflineCapable: true, UsesWorkflowFile: true, Flags: []flag.Flag{ flag.StringFlag{ diff --git a/go.mod b/go.mod index 21c1765dc..7448f024d 100644 --- a/go.mod +++ b/go.mod @@ -41,13 +41,13 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/samber/lo v1.52.0 github.com/sethvargo/go-githubactions v1.3.2 - github.com/speakeasy-api/generation-context v1.0.0 + github.com/speakeasy-api/generation-context v1.1.0 github.com/speakeasy-api/git-diff-parser v0.2.0 github.com/speakeasy-api/gram v0.0.0-20260121234743-5a36906a8929 github.com/speakeasy-api/huh v1.1.2 github.com/speakeasy-api/jq v0.1.1-0.20251107233444-84d7e49e84a4 github.com/speakeasy-api/openapi v1.25.0 - github.com/speakeasy-api/openapi-generation/v2 v2.933.1 + github.com/speakeasy-api/openapi-generation/v2 v2.934.0 github.com/speakeasy-api/sdk-gen-config v1.58.0 github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12 github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.28.1 diff --git a/go.sum b/go.sum index 9d65bee40..44b551cde 100644 --- a/go.sum +++ b/go.sum @@ -530,8 +530,8 @@ github.com/sourcegraph/jsonrpc2 v0.2.2 h1:fCyU80iidEwcF9kWaj4ylOO1h8pT8P8sFGv8EK github.com/sourcegraph/jsonrpc2 v0.2.2/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= github.com/speakeasy-api/easytemplate v0.12.4 h1:0xEm93tqPfdIWKUwgxvFgDLBmSDXlCImp+mQF3XZcOg= github.com/speakeasy-api/easytemplate v0.12.4/go.mod h1:UF8bFhTpyr1sHiEWacT7ULN67Bve6UIrTH7uvzdbTEY= -github.com/speakeasy-api/generation-context v1.0.0 h1:LMHn7k1GCT1lIuwWSCn2ogaUgaIq8eGsGGPUXmvpW0s= -github.com/speakeasy-api/generation-context v1.0.0/go.mod h1:AxqOSyH55Kp70pQynKl0nSlpke6P3/FlnFL//7sPM50= +github.com/speakeasy-api/generation-context v1.1.0 h1:VndzBP6wdkk1yp7DGA/lgAMH+qudMLYf2I5wuXscs+8= +github.com/speakeasy-api/generation-context v1.1.0/go.mod h1:AxqOSyH55Kp70pQynKl0nSlpke6P3/FlnFL//7sPM50= github.com/speakeasy-api/git-diff-parser v0.2.0 h1:fvPsWhTqTt+8j9Kx4eXF6kRRqDUC60gy0VII4PnjIHA= github.com/speakeasy-api/git-diff-parser v0.2.0/go.mod h1:P46HmmVVmwA9P8h2wa0fDpmRM8/grbVQ+uKhWDtpkIY= github.com/speakeasy-api/goja v0.0.0-20260223084236-ed0328a0a462 h1:wFAq/dFgXzPkOpI36BkHdUl4rKi7qh6iMsvlRkh2fCs= @@ -550,8 +550,8 @@ github.com/speakeasy-api/libopenapi v0.21.10-fixhiddencomps-fixed h1:ZtuakKtG6x7 github.com/speakeasy-api/libopenapi v0.21.10-fixhiddencomps-fixed/go.mod h1:Gc8oQkjr2InxwumK0zOBtKN9gIlv9L2VmSVIUk2YxcU= github.com/speakeasy-api/openapi v1.25.0 h1:xyJ5ZzW4YwStT2ICo0o7v9M890J7VpsR7AqhQSZnwl4= github.com/speakeasy-api/openapi v1.25.0/go.mod h1:9gGkzi9jNspEbcB08zta+IjzAi5Zy4QgSEQfF/LSrbQ= -github.com/speakeasy-api/openapi-generation/v2 v2.933.1 h1:g0lZi8XsS2NjArwP1hyC08bJRKeOHjgD9gx9ie7KONQ= -github.com/speakeasy-api/openapi-generation/v2 v2.933.1/go.mod h1:1Yypyh8Dl2dg/aYMY8ySCJ6kfuI9xTpUxuSs30XpZPE= +github.com/speakeasy-api/openapi-generation/v2 v2.934.0 h1:bF3+0Lw4w5WMRE/anhdpUfLTmzOhGAYM7ccLs0VF0Lk= +github.com/speakeasy-api/openapi-generation/v2 v2.934.0/go.mod h1:OOjYkuR25Q5RvpwR5z1EeA9tIKdp3TK4jnXQsNbpmHg= github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4 h1:gV+lYeVNNJG9X3Sl9Su3cRh1iF/oNqzvb5Ijq2QR8jY= github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4/go.mod h1:1zQpVio7X6QJDtyNdUguCgZ+IC7CzKhhjvNgJdvGVF0= github.com/speakeasy-api/sdk-gen-config v1.58.0 h1:JrDgDU3XBIidv+TXFqYBvIomfeGEQ0zN+OnHyUc+kNw= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9f9960440..08206d0da 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,31 +2,157 @@ package auth import ( "context" + "errors" "fmt" "net/http" "os" + "github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/operations" + "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/sdkerrors" + "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" core "github.com/speakeasy-api/speakeasy-core/auth" "github.com/speakeasy-api/speakeasy/internal/config" "github.com/speakeasy-api/speakeasy/internal/interactivity" + "github.com/speakeasy-api/speakeasy/internal/license" "github.com/speakeasy-api/speakeasy/internal/log" "github.com/speakeasy-api/speakeasy/internal/sdk" ) +type licenseContextKey struct{} + +const licenseHint = "For offline authentication, configure offline_license_token or set SPEAKEASY_LICENSE_TOKEN or SPEAKEASY_LICENSE_FILE" + +type coreAuthenticateFunc func(context.Context, string, bool) (context.Context, core.SpeakeasyAuthInfo, error) +type persistAuthInfoFunc func(context.Context, core.SpeakeasyAuthInfo) error +type authenticateWithHintFunc func(context.Context, bool) (context.Context, error) + func Authenticate(ctx context.Context, force bool) (context.Context, error) { - existingKey := config.GetSpeakeasyAPIKey() - authCtx, res, err := core.Authenticate(ctx, existingKey, force) + return authenticate(ctx, config.GetSpeakeasyAPIKey(), force, core.Authenticate, persistAuthInfo) +} + +func authenticate(ctx context.Context, apiKey string, force bool, authenticateCore coreAuthenticateFunc, persist persistAuthInfoFunc) (context.Context, error) { + ctx = context.WithValue(ctx, licenseContextKey{}, (*license.License)(nil)) + ctx = context.WithValue(ctx, core.LicenseTokenKey, []byte(nil)) + + // force ignores the existing API key and opens the browser, letting callers + // such as `speakeasy auth login` replace a revoked key or switch accounts. + authCtx, res, err := authenticateCore(ctx, apiKey, force) if err != nil { return authCtx, err } - if err := config.SetSpeakeasyAuthInfo(authCtx, res); err != nil { + if err := persist(authCtx, res); err != nil { return authCtx, fmt.Errorf("failed to save API key: %w", err) } - return authCtx, nil } +func persistAuthInfo(ctx context.Context, info core.SpeakeasyAuthInfo) error { + return config.SetSpeakeasyAuthInfo(persistableLicenseContext(ctx, info.WorkspaceID), info) +} + +// CommandContext authenticates with the stored offline license when it is usable and with the platform otherwise. +// `speakeasy auth login` is the explicit way to bypass the offline license and refresh the persisted license online. +func CommandContext(ctx context.Context) (context.Context, error) { + return commandContext(ctx, authenticateWithHint) +} + +func commandContext(ctx context.Context, authenticateOnline authenticateWithHintFunc) (context.Context, error) { + lic, warning := license.Resolve(os.Getenv, config.GetOfflineLicenseToken(), config.GetWorkspaceID()) + if warning != "" { + log.From(ctx).Warn(warning) + } + if lic != nil { + licenseCtx, err := license.ContextFromLicense(ctx, lic, config.GetSpeakeasyAPIKey()) + if err == nil { + return context.WithValue(licenseCtx, licenseContextKey{}, lic), nil + } + log.From(ctx).Warn("Could not use the stored offline license; falling back to platform authentication") + } + return authenticateOnline(ctx, false) +} + +// EnsureTargets re-authenticates online when the offline license does not cover every target. +func EnsureTargets(ctx context.Context, targets []string) (context.Context, error) { + lic := licenseFromContext(ctx) + if lic == nil { + return ctx, nil + } + for _, target := range targets { + if !lic.Info.Covers(target) { + return authenticateWithHint(ctx, false) + } + } + return ctx, nil +} + +// EnsurePlatform re-authenticates online when an offline-license context has no SDK client. +func EnsurePlatform(ctx context.Context) (context.Context, error) { + if licenseFromContext(ctx) == nil { + return ctx, nil + } + if _, err := core.GetSDKFromContext(ctx); err == nil { + return ctx, nil + } + return authenticateWithHint(ctx, false) +} + +// WithPlatformFallback runs op and, when an offline-license context is rejected by the platform, re-authenticates and retries once. +func WithPlatformFallback(ctx context.Context, op func(context.Context) error) (context.Context, error) { + err := op(ctx) + if err == nil || licenseFromContext(ctx) == nil || !isAuthenticationFailure(err) { + return ctx, err + } + authCtx, err := authenticateWithHint(ctx, true) + if err != nil { + return authCtx, err + } + return authCtx, op(authCtx) +} + +func authenticateWithHint(ctx context.Context, force bool) (context.Context, error) { + authCtx, err := Authenticate(ctx, force) + if err != nil && config.GetSpeakeasyAPIKey() == "" { + return authCtx, fmt.Errorf("%w. %s", err, licenseHint) + } + return authCtx, err +} + +func licenseFromContext(ctx context.Context) *license.License { + lic, _ := ctx.Value(licenseContextKey{}).(*license.License) + return lic +} + +// HasOfflineLicense reports whether ctx was authenticated with the offline +// license rather than the platform. +func HasOfflineLicense(ctx context.Context) bool { + return licenseFromContext(ctx) != nil +} + +func isAuthenticationFailure(err error) bool { + status := 0 + var sdkErr *sdkerrors.SDKError + var responseErr *sdkerrors.Error + switch { + case errors.As(err, &sdkErr): + status = sdkErr.StatusCode + case errors.As(err, &responseErr): + status = responseErr.StatusCode + } + return status == http.StatusUnauthorized || status == http.StatusForbidden +} + +func persistableLicenseContext(ctx context.Context, workspaceID string) context.Context { + persisted := []byte(nil) + if token, ok := core.GetLicenseTokenFromContext(ctx); ok { + info, err := licensetoken.Inspect(token) + if err == nil && info.Tier != string(shared.AccountTypeFree) && info.WorkspaceID == workspaceID { + persisted = token + } + } + return context.WithValue(ctx, core.LicenseTokenKey, persisted) +} + func UseExistingAPIKeyIfAvailable(ctx context.Context) (context.Context, error) { existingApiKey := config.GetSpeakeasyAPIKey() if existingApiKey == "" { @@ -40,7 +166,7 @@ func UseExistingAPIKeyIfAvailable(ctx context.Context) (context.Context, error) if err != nil { return ctx, err } - _ = config.SetSpeakeasyAuthInfo(ctx, core.SpeakeasyAuthInfo{ + _ = config.SetSpeakeasyAuthInfo(persistableLicenseContext(ctx, workspaceID), core.SpeakeasyAuthInfo{ APIKey: existingApiKey, WorkspaceID: workspaceID, }) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 000000000..36dd5ce31 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,109 @@ +package auth + +import ( + "context" + "slices" + "testing" + + core "github.com/speakeasy-api/speakeasy-core/auth" + "github.com/speakeasy-api/speakeasy/internal/license" +) + +func TestAuthenticateForceIgnoresExistingAPIKey(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), licenseContextKey{}, &license.License{}) + ctx = context.WithValue(ctx, core.LicenseTokenKey, []byte("stale-license")) + freshLicense := []byte("fresh-license") + coreForce := true + persisted := false + + authCtx, err := authenticate( + ctx, + "api-key", + true, + func(ctx context.Context, apiKey string, force bool) (context.Context, core.SpeakeasyAuthInfo, error) { + if apiKey != "api-key" { + t.Fatalf("API key = %q, want api-key", apiKey) + } + if licenseFromContext(ctx) != nil { + t.Fatal("offline license reached core authentication") + } + if token, ok := core.GetLicenseTokenFromContext(ctx); ok || len(token) != 0 { + t.Fatalf("stale license reached core authentication: %q", token) + } + coreForce = force + return context.WithValue(ctx, core.LicenseTokenKey, freshLicense), core.SpeakeasyAuthInfo{ + APIKey: apiKey, + WorkspaceID: "workspace", + }, nil + }, + func(ctx context.Context, info core.SpeakeasyAuthInfo) error { + persisted = true + if info.APIKey != "api-key" || info.WorkspaceID != "workspace" { + t.Fatalf("persisted auth info = %#v", info) + } + token, ok := core.GetLicenseTokenFromContext(ctx) + if !ok || !slices.Equal(token, freshLicense) { + t.Fatalf("persisted license = %q, want %q", token, freshLicense) + } + return nil + }, + ) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + if !coreForce { + t.Fatal("force did not ignore the existing API key for browser authentication") + } + if !persisted { + t.Fatal("refreshed authentication was not persisted") + } + if token, ok := core.GetLicenseTokenFromContext(authCtx); !ok || !slices.Equal(token, freshLicense) { + t.Fatalf("authentication context license = %q, want %q", token, freshLicense) + } +} + +func TestAuthenticateForceUsesBrowserWithoutAPIKey(t *testing.T) { + t.Parallel() + + coreForce := false + _, err := authenticate( + context.Background(), + "", + true, + func(ctx context.Context, _ string, force bool) (context.Context, core.SpeakeasyAuthInfo, error) { + coreForce = force + return ctx, core.SpeakeasyAuthInfo{}, nil + }, + func(context.Context, core.SpeakeasyAuthInfo) error { return nil }, + ) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + if !coreForce { + t.Fatal("force did not request browser authentication without an API key") + } +} + +func TestCommandContextFallsBackToPlatformWithoutOfflineLicense(t *testing.T) { + // An unusable env token keeps license resolution deterministic regardless of + // any offline license persisted in the developer's real CLI config. + t.Setenv("SPEAKEASY_LICENSE_TOKEN", "not-a-license") + + wantCtx := context.WithValue(context.Background(), core.WorkspaceIDKey, "online-workspace") + called := false + ctx, err := commandContext(context.Background(), func(_ context.Context, force bool) (context.Context, error) { + called = true + if force { + t.Fatal("command context forced online re-authentication") + } + return wantCtx, nil + }) + if err != nil { + t.Fatalf("command context: %v", err) + } + if !called || ctx != wantCtx { + t.Fatal("command context did not fall back to platform authentication") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d2010d13b..f2d498157 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,10 @@ func GetWorkspaceID() string { return vCfg.GetString("speakeasy_workspace_id") } +func GetOfflineLicenseToken() string { + return vCfg.GetString("offline_license_token") +} + func GetStudioSecret() string { return vCfg.GetString("speakeasy_studio_secret") } @@ -103,11 +107,21 @@ func SetStudioSecret(secret string) error { func SetSpeakeasyAuthInfo(ctx context.Context, info core.SpeakeasyAuthInfo) error { // Keep speakeasy-self as default workspace - if vCfg.GetString("speakeasy_workspace_id") != "self" { + defaultWorkspaceID := vCfg.GetString("speakeasy_workspace_id") + keepingSelfDefault := defaultWorkspaceID == "self" && info.WorkspaceID != "self" + if !keepingSelfDefault { + // A stored offline license may be manually configured or come from a + // platform that does not issue tokens; only replace it when this + // authentication produced one, and drop it when the workspace changes. + if token, ok := core.GetLicenseTokenFromContext(ctx); ok { + vCfg.Set("offline_license_token", string(token)) + } else if defaultWorkspaceID != info.WorkspaceID { + vCfg.Set("offline_license_token", "") + } vCfg.Set("speakeasy_api_key", info.APIKey) vCfg.Set("speakeasy_workspace_id", info.WorkspaceID) vCfg.Set("speakeasy_customer_id", info.CustomerID) - } else if info.WorkspaceID != "self" { + } else { println(styles.DimmedItalic.Render("Keeping speakeasy-self as default workspace. New workspace will still be usable as a registry source. Logout first if you want to change default workspaces\n")) } @@ -128,6 +142,7 @@ func ClearSpeakeasyAuthInfo() error { vCfg.Set("speakeasy_workspace_id", "") vCfg.Set("speakeasy_customer_id", "") vCfg.Set("speakeasy_studio_secret", "") + vCfg.Set("offline_license_token", "") return save() } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 000000000..2e1d4c3d5 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "context" + "testing" + + core "github.com/speakeasy-api/speakeasy-core/auth" + "github.com/spf13/viper" +) + +func TestSetSpeakeasyAuthInfoOfflineLicensePersistence(t *testing.T) { //nolint:paralleltest + originalCfg, originalDir := vCfg, cfgDir + t.Cleanup(func() { vCfg, cfgDir = originalCfg, originalDir }) + cfgDir = t.TempDir() + vCfg = viper.New() + vCfg.SetConfigName("config") + vCfg.SetConfigType("yaml") + vCfg.AddConfigPath(cfgDir) + + vCfg.Set("speakeasy_workspace_id", "workspace-a") + vCfg.Set("offline_license_token", "stored-token") + + info := core.SpeakeasyAuthInfo{APIKey: "api-key", WorkspaceID: "workspace-a"} + + if err := SetSpeakeasyAuthInfo(context.Background(), info); err != nil { + t.Fatalf("set auth info without token: %v", err) + } + if got := GetOfflineLicenseToken(); got != "stored-token" { + t.Fatalf("token-less authentication replaced the stored offline license: %q", got) + } + + freshCtx := context.WithValue(context.Background(), core.LicenseTokenKey, []byte("fresh-token")) + if err := SetSpeakeasyAuthInfo(freshCtx, info); err != nil { + t.Fatalf("set auth info with fresh token: %v", err) + } + if got := GetOfflineLicenseToken(); got != "fresh-token" { + t.Fatalf("fresh license token was not persisted: %q", got) + } + + other := core.SpeakeasyAuthInfo{APIKey: "api-key", WorkspaceID: "workspace-b"} + if err := SetSpeakeasyAuthInfo(context.Background(), other); err != nil { + t.Fatalf("set auth info for other workspace: %v", err) + } + if got := GetOfflineLicenseToken(); got != "" { + t.Fatalf("workspace change kept the previous workspace's offline license: %q", got) + } +} diff --git a/internal/license/license.go b/internal/license/license.go new file mode 100644 index 000000000..d9d3d08e3 --- /dev/null +++ b/internal/license/license.go @@ -0,0 +1,153 @@ +package license + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + speakeasy "github.com/speakeasy-api/speakeasy-client-sdk-go/v3" + "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" + core "github.com/speakeasy-api/speakeasy-core/auth" + "github.com/speakeasy-api/speakeasy/internal/log" + + "github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken" +) + +const ( + licenseTokenEnvironment = "SPEAKEASY_LICENSE_TOKEN" + licenseFileEnvironment = "SPEAKEASY_LICENSE_FILE" +) + +type License struct { + Token []byte + Info licensetoken.TokenInfo +} + +var inspect = licensetoken.Inspect + +// Resolve returns the first configured license source when it validates for the workspace, otherwise nil and a warning naming the source. +func Resolve(getenv func(string) string, configToken string, workspaceID string) (*License, string) { + resolve := func(token []byte, source string) (*License, string) { + token = []byte(strings.TrimSpace(string(token))) + info, err := inspect(token) + if err == nil && usable(info, workspaceID) { + return &License{Token: token, Info: info}, "" + } + if source == "" { + return nil, "" + } + return nil, "Ignoring unusable " + source + "; falling back to platform authentication" + } + + if token := strings.TrimSpace(getenv(licenseTokenEnvironment)); token != "" { + return resolve([]byte(token), licenseTokenEnvironment) + } + if path := strings.TrimSpace(getenv(licenseFileEnvironment)); path != "" { + token, err := os.ReadFile(path) + if err != nil { + return nil, "Ignoring unreadable " + licenseFileEnvironment + "; falling back to platform authentication" + } + return resolve(token, licenseFileEnvironment) + } + if token := strings.TrimSpace(configToken); token != "" { + return resolve([]byte(token), "") + } + return nil, "" +} + +func usable(info licensetoken.TokenInfo, workspaceID string) bool { + return workspaceID == "" || info.WorkspaceID == workspaceID +} + +type payload struct { + License licenseClaims `json:"license"` +} + +type licenseClaims struct { + OrgID string `json:"org_id"` + Features []string `json:"features"` + AddOns []string `json:"add_ons"` + TelemetryDisabled bool `json:"telemetry_disabled"` + WorkspaceCreatedAt time.Time `json:"workspace_created_at"` +} + +func decodeClaims(token []byte) (licenseClaims, error) { + parts := strings.Split(string(token), ".") + if len(parts) != 3 { + return licenseClaims{}, fmt.Errorf("invalid license token payload") + } + payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return licenseClaims{}, fmt.Errorf("decode license token payload: %w", err) + } + var decoded payload + if err := json.Unmarshal(payloadJSON, &decoded); err != nil { + return licenseClaims{}, fmt.Errorf("decode license token claims: %w", err) + } + return decoded.License, nil +} + +func ContextFromLicense(ctx context.Context, lic *License, apiKey string) (context.Context, error) { + claims, err := decodeClaims(lic.Token) + if err != nil { + return ctx, err + } + + accountType, err := accountTypeFromTier(lic.Info.Tier) + if err != nil { + return ctx, err + } + featureFlags := make([]string, 0, len(claims.Features)) + for _, feature := range claims.Features { + candidate := shared.WorkspaceFeatureFlag(feature) + if candidate.IsExact() { + featureFlags = append(featureFlags, string(candidate)) + } + } + addOns := make([]shared.BillingAddOn, 0, len(claims.AddOns)) + for _, addOn := range claims.AddOns { + candidate := shared.BillingAddOn(addOn) + if candidate.IsExact() { + addOns = append(addOns, candidate) + } + } + + if apiKey != "" { + security := shared.Security{APIKey: &apiKey} + sdk := speakeasy.New( + speakeasy.WithSecurity(security), + speakeasy.WithServerURL(core.GetServerURL()), + speakeasy.WithWorkspaceID(lic.Info.WorkspaceID), + speakeasy.WithClient(http.DefaultClient), + ) + ctx = context.WithValue(ctx, core.SpeakeasySDKKey, sdk) + } + ctx = context.WithValue(ctx, core.WorkspaceIDKey, lic.Info.WorkspaceID) + ctx = context.WithValue(ctx, core.AccountTypeKey, accountType) + ctx = context.WithValue(ctx, core.WorkspaceFeatureFlagsKey, featureFlags) + ctx = context.WithValue(ctx, core.OrgSlugKey, lic.Info.OrgSlug) + ctx = context.WithValue(ctx, core.WorkspaceSlugKey, lic.Info.WorkspaceSlug) + ctx = context.WithValue(ctx, core.WorkspaceCreatedAtKey, claims.WorkspaceCreatedAt) + ctx = context.WithValue(ctx, core.TelemetryDisabledSlug, claims.TelemetryDisabled || apiKey == "") + ctx = context.WithValue(ctx, core.BillingAddOnsKey, addOns) + ctx = context.WithValue(ctx, core.LicenseTokenKey, append([]byte(nil), lic.Token...)) + + log.From(ctx).Infof("Using the offline license for %s (expires %s); skipping platform authentication", lic.Info.WorkspaceSlug, lic.Info.ExpiresAt.Format(time.DateOnly)) + return ctx, nil +} + +func accountTypeFromTier(tier string) (shared.AccountType, error) { + if tier == "oss" { + return shared.AccountTypeEnterprise, nil + } + candidate := shared.AccountType(tier) + if !candidate.IsExact() || candidate == shared.AccountTypeOss { + return "", fmt.Errorf("unsupported license tier %q", tier) + } + return candidate, nil +} diff --git a/internal/license/license_test.go b/internal/license/license_test.go new file mode 100644 index 000000000..1349ed9ea --- /dev/null +++ b/internal/license/license_test.go @@ -0,0 +1,220 @@ +package license + +import ( + "context" + "encoding/base64" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken" + "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" + core "github.com/speakeasy-api/speakeasy-core/auth" + "github.com/speakeasy-api/speakeasy/registry" +) + +func TestResolveSourcePrecedence(t *testing.T) { //nolint:paralleltest + originalInspect := inspect + t.Cleanup(func() { inspect = originalInspect }) + inspect = func(token []byte) (licensetoken.TokenInfo, error) { + if string(token) == "invalid-token" { + return licensetoken.TokenInfo{}, errors.New("invalid") + } + return licensetoken.TokenInfo{WorkspaceID: "workspace", Targets: []string{"*"}, Message: string(token)}, nil + } + + licenseFile := filepath.Join(t.TempDir(), "license.jwt") + if err := os.WriteFile(licenseFile, []byte("file-token"), 0o600); err != nil { + t.Fatalf("write license file: %v", err) + } + invalidLicenseFile := filepath.Join(t.TempDir(), "invalid-license.jwt") + if err := os.WriteFile(invalidLicenseFile, []byte("invalid-token"), 0o600); err != nil { + t.Fatalf("write invalid license file: %v", err) + } + + tests := []struct { + name string + environment map[string]string + configToken string + wantToken string + wantWarning string + }{ + {name: "environment", environment: map[string]string{licenseTokenEnvironment: "environment-token", licenseFileEnvironment: filepath.Join(t.TempDir(), "missing.jwt")}, configToken: "config-token", wantToken: "environment-token"}, + {name: "invalid environment overrides file and config", environment: map[string]string{licenseTokenEnvironment: "invalid-token", licenseFileEnvironment: licenseFile}, configToken: "config-token", wantWarning: licenseTokenEnvironment}, + {name: "file", environment: map[string]string{licenseFileEnvironment: licenseFile}, configToken: "config-token", wantToken: "file-token"}, + {name: "invalid file overrides config", environment: map[string]string{licenseFileEnvironment: invalidLicenseFile}, configToken: "config-token", wantWarning: licenseFileEnvironment}, + {name: "config", environment: map[string]string{}, configToken: "config-token", wantToken: "config-token"}, + } + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { + lic, warning := Resolve(func(key string) string { return tt.environment[key] }, tt.configToken, "workspace") + token := "" + if lic != nil { + token = string(lic.Token) + } + if token != tt.wantToken { + t.Fatalf("license token = %q, want %q", token, tt.wantToken) + } + if tt.wantWarning != "" && !strings.Contains(warning, tt.wantWarning) { + t.Fatalf("warning = %q, want source %q", warning, tt.wantWarning) + } + if tt.wantWarning == "" && warning != "" { + t.Fatalf("unexpected warning = %q", warning) + } + }) + } +} + +func TestResolveSkipsUnusableCandidates(t *testing.T) { //nolint:paralleltest + originalInspect := inspect + t.Cleanup(func() { inspect = originalInspect }) + + tests := []struct { + name string + info licensetoken.TokenInfo + inspectErr error + }{ + {name: "other workspace", info: licensetoken.TokenInfo{WorkspaceID: "other", Targets: []string{"*"}}}, + {name: "expired", inspectErr: errors.New("expired")}, + {name: "invalid", inspectErr: errors.New("bad signature")}, + } + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { + inspect = func([]byte) (licensetoken.TokenInfo, error) { return tt.info, tt.inspectErr } + lic, warning := Resolve(func(key string) string { + if key == licenseTokenEnvironment { + return "secret-token" + } + return "" + }, "", "workspace") + if lic != nil || warning == "" { + t.Fatalf("resolution = %#v, warning = %q", lic, warning) + } + if strings.Contains(warning, "secret-token") { + t.Fatal("warning contains the license token") + } + }) + } +} + +func TestResolveUnreadableFileOverridesConfig(t *testing.T) { //nolint:paralleltest + originalInspect := inspect + t.Cleanup(func() { inspect = originalInspect }) + inspect = func(token []byte) (licensetoken.TokenInfo, error) { + return licensetoken.TokenInfo{WorkspaceID: "workspace", Message: string(token)}, nil + } + + lic, warning := Resolve(func(key string) string { + if key == licenseFileEnvironment { + return filepath.Join(t.TempDir(), "missing.jwt") + } + return "" + }, "config-token", "workspace") + if lic != nil || !strings.Contains(warning, licenseFileEnvironment) { + t.Fatalf("resolution = %#v, warning = %q", lic, warning) + } +} + +func TestResolveSilentlyIgnoresUnusableConfigToken(t *testing.T) { //nolint:paralleltest + originalInspect := inspect + t.Cleanup(func() { inspect = originalInspect }) + inspect = func([]byte) (licensetoken.TokenInfo, error) { + return licensetoken.TokenInfo{}, errors.New("invalid") + } + + lic, warning := Resolve(func(string) string { return "" }, "invalid-config-token", "workspace") + if warning != "" { + t.Fatalf("unexpected warning = %q", warning) + } + if lic != nil { + t.Fatalf("resolution = %#v, want nil", lic) + } +} + +func TestContextFromLicense(t *testing.T) { + t.Parallel() + + createdAt := time.Date(2024, time.March, 2, 1, 2, 3, 0, time.UTC) + token := testToken(`{"license":{"org_id":"org","features":["schema_registry","unknown"],"add_ons":["sdk_testing","unknown"],"telemetry_disabled":false,"workspace_created_at":"2024-03-02T01:02:03Z"}}`) + lic := &License{ + Token: token, + Info: licensetoken.TokenInfo{ + WorkspaceID: "workspace", + WorkspaceSlug: "workspace-slug", + OrgSlug: "org-slug", + Tier: "oss", + ExpiresAt: time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + } + + ctx, err := ContextFromLicense(context.Background(), lic, "") + if err != nil { + t.Fatalf("context from license: %v", err) + } + workspaceID, err := core.GetWorkspaceIDFromContext(ctx) + if err != nil || workspaceID != "workspace" { + t.Fatalf("workspace ID = %q, %v", workspaceID, err) + } + accountType := core.GetAccountTypeFromContext(ctx) + if accountType == nil || *accountType != shared.AccountTypeEnterprise { + t.Fatalf("account type = %v, want enterprise", accountType) + } + if enabled, err := core.HasWorkspaceFeatureFlag(ctx, string(shared.WorkspaceFeatureFlagSchemaRegistry)); err != nil || !enabled { + t.Fatalf("schema registry feature = %t, %v", enabled, err) + } + if enabled, err := core.HasWorkspaceFeatureFlag(ctx, "unknown"); err != nil || enabled { + t.Fatalf("unknown feature = %t, %v", enabled, err) + } + if !core.IsTelemetryDisabled(ctx) { + t.Fatal("telemetry is enabled without an API key") + } + if registry.IsRegistryEnabled(ctx) { + t.Fatal("registry is enabled without an API key") + } + if got := core.GetWorkspaceCreatedAtFromContext(ctx); got == nil || !got.Equal(createdAt) { + t.Fatalf("workspace created at = %v, want %v", got, createdAt) + } + tokenFromContext, ok := core.GetLicenseTokenFromContext(ctx) + if !ok || !slices.Equal(tokenFromContext, token) { + t.Fatal("license token missing from context") + } + if core.GetOrgSlugFromContext(ctx) != "org-slug" || core.GetWorkspaceSlugFromContext(ctx) != "workspace-slug" { + t.Fatalf("slugs = %q/%q", core.GetOrgSlugFromContext(ctx), core.GetWorkspaceSlugFromContext(ctx)) + } + if enabled, err := core.HasBillingAddOn(ctx, shared.BillingAddOnSDKTesting); err != nil || !enabled { + t.Fatalf("SDK testing add-on = %t, %v", enabled, err) + } + if _, err := core.GetSDKFromContext(ctx); err == nil { + t.Fatal("SDK present without API key") + } + + withSDK, err := ContextFromLicense(context.Background(), lic, "api-key") + if err != nil { + t.Fatalf("context with SDK: %v", err) + } + if _, err := core.GetSDKFromContext(withSDK); err != nil { + t.Fatalf("SDK missing with API key: %v", err) + } + if core.IsTelemetryDisabled(withSDK) { + t.Fatal("telemetry claim was overridden with an API key") + } +} + +func TestTokenInfoCoversTargets(t *testing.T) { + t.Parallel() + + if !(licensetoken.TokenInfo{Targets: []string{"go"}}).Covers("go") { + t.Fatal("exact target is not covered") + } + if !(licensetoken.TokenInfo{Targets: []string{"*"}}).Covers("typescript") { + t.Fatal("wildcard target is not covered") + } +} + +func testToken(payload string) []byte { + return []byte("header." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + ".signature") +} diff --git a/internal/model/command.go b/internal/model/command.go index 09c07340e..ab24c2520 100644 --- a/internal/model/command.go +++ b/internal/model/command.go @@ -21,6 +21,7 @@ import ( "github.com/sethvargo/go-githubactions" "github.com/speakeasy-api/sdk-gen-config/workflow" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" + core "github.com/speakeasy-api/speakeasy-core/auth" "github.com/speakeasy-api/speakeasy-core/events" "github.com/speakeasy-api/speakeasy/internal/auth" "github.com/speakeasy-api/speakeasy/internal/charm/styles" @@ -100,6 +101,13 @@ type ExecutableCommand[F interface{}] struct { // context. RequiresAuth bool + // When enabled alongside RequiresAuth, a usable offline license + // authenticates the command without reaching the platform. Commands that + // call platform APIs must leave this unset so they always authenticate + // online; offline-capable commands re-authenticate on demand via + // auth.EnsureTargets, auth.EnsurePlatform, or auth.WithPlatformFallback. + OfflineCapable bool + // When enabled, the command uses a workflow file. If the "pinned" CLI flag // is not present or set to false and the execution environment is not // local, run using the CLI version specified in the workflow file. @@ -136,16 +144,24 @@ func (c ExecutableCommand[F]) Init() (*cobra.Command, error) { } if c.RequiresAuth { - authCtx, err := auth.Authenticate(cmd.Context(), false) + var authCtx context.Context + var err error + if c.OfflineCapable { + authCtx, err = auth.CommandContext(cmd.Context()) + } else { + authCtx, err = auth.Authenticate(cmd.Context(), false) + } if err != nil { cmd.SilenceUsage = true return err } cmd.SetContext(authCtx) - if err := auth.ConfirmWorkspace(authCtx); err != nil { - cmd.SilenceUsage = true - return err + if _, err := core.GetSDKFromContext(authCtx); err == nil { + if err := auth.ConfirmWorkspace(authCtx); err != nil { + cmd.SilenceUsage = true + return err + } } } else { authCtx, err := auth.UseExistingAPIKeyIfAvailable(cmd.Context()) diff --git a/internal/run/generation_context_test.go b/internal/run/generation_context_test.go deleted file mode 100644 index 222868ab8..000000000 --- a/internal/run/generation_context_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package run - -import ( - "context" - "testing" - - generationaccess "github.com/speakeasy-api/generation-context/access" -) - -func TestWithWorkflowGenerationContextPreservesExistingState(t *testing.T) { - t.Parallel() - - original := generationaccess.WithDirect(context.Background()) - - ctx, err := withWorkflowGenerationContext(original) - if err != nil { - t.Fatalf("with workflow generation context: %v", err) - } - if ctx != original { - t.Fatal("existing state must be returned unchanged") - } - state, ok := generationaccess.StateFromContext(ctx) - if !ok || state.Mode() != generationaccess.ModeDirect { - t.Fatalf("existing direct state was not preserved: %#v", state) - } -} - -func TestWithWorkflowGenerationContextUnauthenticatedIsDirect(t *testing.T) { - t.Parallel() - - ctx, err := withWorkflowGenerationContext(context.Background()) - if err != nil { - t.Fatalf("with workflow generation context: %v", err) - } - state, ok := generationaccess.StateFromContext(ctx) - if !ok { - t.Fatal("expected direct state for unauthenticated invocation") - } - if state.Mode() != generationaccess.ModeDirect { - t.Fatalf("unexpected mode %d", state.Mode()) - } - if state.GeneratedLicense() != generationaccess.GeneratedLicenseAGPL { - t.Fatalf("direct generation must be AGPL, got %q", state.GeneratedLicense()) - } -} diff --git a/internal/run/github.go b/internal/run/github.go index e30a3fb4d..fae9a9999 100644 --- a/internal/run/github.go +++ b/internal/run/github.go @@ -11,6 +11,7 @@ import ( "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/operations" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" "github.com/speakeasy-api/speakeasy-core/auth" + cliauth "github.com/speakeasy-api/speakeasy/internal/auth" "github.com/speakeasy-api/speakeasy/internal/charm/styles" "github.com/speakeasy-api/speakeasy/internal/interactivity" "github.com/speakeasy-api/speakeasy/internal/log" @@ -31,6 +32,10 @@ func isRunning(status string) bool { } func RunGitHub(ctx context.Context, target, version string, force bool) error { + ctx, err := cliauth.EnsurePlatform(ctx) + if err != nil { + return err + } sdk, err := auth.GetSDKFromContext(ctx) if err != nil { return fmt.Errorf("failed to get sdk from context: %w", err) @@ -128,6 +133,10 @@ func RunGitHub(ctx context.Context, target, version string, force bool) error { } func RunGitHubRepos(ctx context.Context, target, version string, force bool, githubRepos string) error { + ctx, err := cliauth.EnsurePlatform(ctx) + if err != nil { + return err + } if githubRepos == "all" { return runGitHubReposAll(ctx, target, version, force) } diff --git a/internal/run/run.go b/internal/run/run.go index dd63841d7..c23c37973 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -12,7 +12,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/samber/lo" - generationaccess "github.com/speakeasy-api/generation-context/access" "gopkg.in/yaml.v3" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" @@ -20,6 +19,7 @@ import ( core "github.com/speakeasy-api/speakeasy-core/auth" "github.com/speakeasy-api/speakeasy-core/errors" "github.com/speakeasy-api/speakeasy-core/events" + cliauth "github.com/speakeasy-api/speakeasy/internal/auth" "github.com/speakeasy-api/speakeasy/internal/charm/styles" "github.com/speakeasy-api/speakeasy/internal/github" "github.com/speakeasy-api/speakeasy/internal/log" @@ -30,6 +30,8 @@ import ( const ErrNoRollback = errors.Error("failed with error that shouldn't be rolled back") +var ensureTargets = cliauth.EnsureTargets + type SourceStep interface { Do(ctx context.Context, inputPath string) (string, error) } @@ -129,9 +131,9 @@ func (w *Workflow) PrintSuccessSummary(ctx context.Context) { } func (w *Workflow) Run(ctx context.Context) error { - ctx, generationContextErr := withWorkflowGenerationContext(ctx) - if generationContextErr != nil { - return fmt.Errorf("failed to prepare generation context: %w", generationContextErr) + ctx, workflowContextErr := w.prepareWorkflowContext(ctx) + if workflowContextErr != nil { + return fmt.Errorf("failed to prepare workflow context: %w", workflowContextErr) } startTime := time.Now() @@ -173,25 +175,48 @@ func (w *Workflow) Run(ctx context.Context) error { return err } -func withWorkflowGenerationContext(ctx context.Context) (context.Context, error) { - if _, ok := generationaccess.StateFromContext(ctx); ok { - return ctx, nil +func (w *Workflow) prepareWorkflowContext(ctx context.Context) (context.Context, error) { + targetTypes, err := w.selectedTargetTypes() + if err != nil { + return ctx, err } - - if _, err := core.GetWorkspaceIDFromContext(ctx); err != nil { - return generationaccess.WithDirect(ctx), nil //nolint:nilerr // Direct runs do not carry workspace context. + ctx, err = ensureTargets(ctx, targetTypes) + if err != nil { + return ctx, err } - - // Target-level access checks decide whether authenticated output is commercial. - // The workflow-level state only establishes the authenticated invocation context. - return core.WithGenerationContext(ctx, generationaccess.GeneratedLicenseAGPL) + return ctx, nil } -func (w *Workflow) RunInner(ctx context.Context) error { +func (w *Workflow) selectedTargetTypes() ([]string, error) { if w.Source != "" && w.Target != "" { - return fmt.Errorf("cannot specify both a target and a source") + return nil, fmt.Errorf("cannot specify both a target and a source") } + targetIDs := []string{w.Target} + if w.Target == "all" { + targetIDs = lo.Keys(w.workflow.Targets) + } + targetTypes := make([]string, 0, len(targetIDs)) + seenTargetTypes := make(map[string]struct{}, len(targetIDs)) + for _, targetID := range targetIDs { + if targetID == "" { + continue + } + target, ok := w.workflow.Targets[targetID] + if !ok { + return nil, fmt.Errorf("target '%s' not found", targetID) + } + if _, ok := seenTargetTypes[target.Target]; ok { + continue + } + seenTargetTypes[target.Target] = struct{}{} + targetTypes = append(targetTypes, target.Target) + } + slices.Sort(targetTypes) + return targetTypes, nil +} + +func (w *Workflow) RunInner(ctx context.Context) error { sourceIDs := []string{w.Source} if w.Source == "all" { sourceIDs = lo.Keys(w.workflow.Sources) @@ -200,7 +225,6 @@ func (w *Workflow) RunInner(ctx context.Context) error { if w.Target == "all" { targetIDs = lo.Keys(w.workflow.Targets) } - if w.SetVersion != "" && len(targetIDs) > 1 { return fmt.Errorf("cannot manually apply a version when more than one target is specified ") } @@ -226,9 +250,6 @@ func (w *Workflow) RunInner(ctx context.Context) error { if targetID == "" { continue } - if _, ok := w.workflow.Targets[targetID]; !ok { - return fmt.Errorf("target '%s' not found", targetID) - } _, _, err := w.runTarget(ctx, targetID) if err != nil { return err diff --git a/internal/sdkgen/generation_context_test.go b/internal/sdkgen/generation_context_test.go index 0cffd8b2f..fedf79a39 100644 --- a/internal/sdkgen/generation_context_test.go +++ b/internal/sdkgen/generation_context_test.go @@ -2,25 +2,29 @@ package sdkgen import ( "context" + "errors" "testing" "time" generationaccess "github.com/speakeasy-api/generation-context/access" + "github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" "github.com/speakeasy-api/speakeasy-core/auth" ) -func TestWithGenerationContextSelectsCallerDecidedLicense(t *testing.T) { +func TestWithGenerationContextAlwaysElectsCommercial(t *testing.T) { t.Parallel() createdAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) testCases := []struct { - name string - generationAllowed bool - wantLicense generationaccess.GeneratedLicense + name string + licenseToken []byte + wantLicense generationaccess.GeneratedLicense + wantToken bool }{ - {name: "allowed generation is commercial", generationAllowed: true, wantLicense: generationaccess.GeneratedLicenseCommercial}, - {name: "unentitled generation is AGPL", generationAllowed: false, wantLicense: generationaccess.GeneratedLicenseAGPL}, + {name: "license token elects commercial and attaches the token", licenseToken: []byte("license-token"), wantLicense: generationaccess.GeneratedLicenseCommercial, wantToken: true}, + {name: "no license token still elects commercial", wantLicense: generationaccess.GeneratedLicenseCommercial}, + {name: "empty license token still elects commercial", licenseToken: []byte{}, wantLicense: generationaccess.GeneratedLicenseCommercial}, } for _, tt := range testCases { @@ -37,7 +41,7 @@ func TestWithGenerationContextSelectsCallerDecidedLicense(t *testing.T) { []shared.BillingAddOn{shared.BillingAddOnSnippetAi}, ) - ctx, err := withGenerationContext(legacy, tt.generationAllowed) + ctx, err := withGenerationContext(legacy, tt.licenseToken) if err != nil { t.Fatalf("with generation context: %v", err) } @@ -54,6 +58,14 @@ func TestWithGenerationContextSelectsCallerDecidedLicense(t *testing.T) { if !state.HasBillingAddOn(shared.BillingAddOnSnippetAi) { t.Fatal("caller bridge lost authenticated billing add-ons") } + _, verification, err := licensetoken.ResolveGenerationAccess(ctx) + if tt.wantToken { + if !errors.Is(err, licensetoken.ErrInvalidLicenseToken) { + t.Fatalf("expected the attached fixture token to reach the validator and be rejected, got verification=%v err=%v", verification, err) + } + } else if err != nil || verification != nil { + t.Fatalf("expected no license token attached, got verification=%v err=%v", verification, err) + } }) } } @@ -68,7 +80,7 @@ func TestWithGenerationContextPreservesCancellableContext(t *testing.T) { cancellable, cancel := context.WithCancel(legacy) defer cancel() - ctx, err := withGenerationContext(cancellable, true) + ctx, err := withGenerationContext(cancellable, []byte("license-token")) if err != nil { t.Fatalf("with generation context: %v", err) } @@ -86,7 +98,7 @@ func TestWithGenerationContextRejectsIncompleteAuthentication(t *testing.T) { t.Parallel() original := auth.SetAccountTypeInContext(context.Background(), string(shared.AccountTypeBusiness)) - ctx, err := withGenerationContext(original, true) + ctx, err := withGenerationContext(original, []byte("license-token")) if err == nil { t.Fatal("expected incomplete authentication error") } diff --git a/internal/sdkgen/sdkgen.go b/internal/sdkgen/sdkgen.go index a47fe79b9..cfab0b8fc 100644 --- a/internal/sdkgen/sdkgen.go +++ b/internal/sdkgen/sdkgen.go @@ -11,13 +11,14 @@ import ( "github.com/charmbracelet/lipgloss" generationaccess "github.com/speakeasy-api/generation-context/access" - "github.com/speakeasy-api/speakeasy-core/auth" + coreauth "github.com/speakeasy-api/speakeasy-core/auth" "github.com/speakeasy-api/speakeasy-core/openapi" config "github.com/speakeasy-api/sdk-gen-config" "github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared" "github.com/speakeasy-api/speakeasy-core/access" "github.com/speakeasy-api/speakeasy-core/events" + cliauth "github.com/speakeasy-api/speakeasy/internal/auth" "github.com/speakeasy-api/speakeasy/internal/charm/styles" "github.com/speakeasy-api/speakeasy/internal/env" "github.com/speakeasy-api/speakeasy/internal/fs" @@ -27,6 +28,7 @@ import ( changelog "github.com/speakeasy-api/openapi-generation/v2" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" + "github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken" "github.com/speakeasy-api/openapi-generation/v2/pkg/merge" "github.com/speakeasy-api/speakeasy/internal/log" "github.com/speakeasy-api/speakeasy/internal/utils" @@ -38,6 +40,13 @@ import ( // It defaults to the real prompt implementation. var PromptForCustomCode = prompts.PromptForCustomCode +var ( + checkGenerationAccess = access.CheckGenerationAccess + ensureTargets = cliauth.EnsureTargets + hasOfflineLicense = cliauth.HasOfflineLicense + withPlatformFallback = cliauth.WithPlatformFallback +) + type GenerationAccess struct { AccessAllowed bool Message string @@ -102,13 +111,14 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err logger := log.From(ctx).WithAssociatedFile(opts.SchemaPath) - generationAccess, level, message, accessErr := access.HasGenerationAccess(ctx, &access.GenerationAccessArgs{ + ctx, accessResult, licenseToken, accessErr := evaluateGenerationAccess(ctx, &access.GenerationAccessArgs{ GenLockID: GetGenLockID(opts.OutDir), TargetType: &opts.Language, - }) + }, opts.Language) if accessErr != nil { return &GenerationAccess{}, fmt.Errorf("failed to evaluate generation access: %w", accessErr) } + generationAccess, level, message := accessResult.Allowed, accessResult.Level, accessResult.Message if !generationAccess && level != nil && *level == shared.LevelBlocked { msg := styles.RenderErrorMessage( @@ -124,7 +134,7 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err }, stderrors.New("generation access blocked") } - ctx, err := withGenerationContext(ctx, generationAccess) + ctx, err := withGenerationContext(ctx, licenseToken) if err != nil { return &GenerationAccess{ AccessAllowed: generationAccess, @@ -132,6 +142,9 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err Level: level, }, fmt.Errorf("failed to prepare generation context: %w", err) } + if len(licenseToken) == 0 { + logger.Warnf("No license token was issued for this generation; the platform may need to be updated to issue commercial license tokens.") + } logger.Infof("Generating SDK for %s...\n", opts.Language) @@ -162,7 +175,7 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err runLocation = "cli" } - workspaceUri := auth.GetWorkspaceBaseURL(ctx) + workspaceUri := coreauth.GetWorkspaceBaseURL(ctx) generatorOpts := []generate.GeneratorOptions{ generate.WithLogger(logger.WithFormatter(log.PrefixedFormatter)), @@ -276,7 +289,7 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err var errs []error if opts.CancellableGeneration != nil && opts.CancellableGeneration.CancellableContext != nil { - cancelCtx, err := withGenerationContext(opts.CancellableGeneration.CancellableContext, generationAccess) + cancelCtx, err := withGenerationContext(opts.CancellableGeneration.CancellableContext, licenseToken) if err != nil { return fmt.Errorf("failed to prepare cancellable generation context: %w", err) } @@ -322,8 +335,8 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err cliEvent := events.GetTelemetryEventFromContext(ctx) if cliEvent != nil && cliEvent.ExecutionID != "" { // Get org and workspace slugs from context - orgSlug := auth.GetOrgSlugFromContext(ctx) - workspaceSlug := auth.GetWorkspaceSlugFromContext(ctx) + orgSlug := coreauth.GetOrgSlugFromContext(ctx) + workspaceSlug := coreauth.GetWorkspaceSlugFromContext(ctx) if orgSlug != "" && workspaceSlug != "" { logger.Successf("speakeasy repro %s_%s_%s", orgSlug, workspaceSlug, cliEvent.ExecutionID) @@ -348,12 +361,54 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err }, nil } -func withGenerationContext(ctx context.Context, generationAllowed bool) (context.Context, error) { - generatedLicense := generationaccess.GeneratedLicenseAGPL - if generationAllowed { - generatedLicense = generationaccess.GeneratedLicenseCommercial +func evaluateGenerationAccess(ctx context.Context, args *access.GenerationAccessArgs, target string) (context.Context, *access.GenerationAccess, []byte, error) { + ctx, err := ensureTargets(ctx, []string{target}) + if err != nil { + return ctx, nil, nil, err + } + + licenseToken, _ := coreauth.GetLicenseTokenFromContext(ctx) + // An offline-license context still carries an SDK client when an API key is + // persisted; the license decides generation access there, so the platform + // access check (and therefore the network) must not be required. + if hasOfflineLicense(ctx) && len(licenseToken) > 0 { + return ctx, &access.GenerationAccess{Allowed: true}, licenseToken, nil + } + if _, err := coreauth.GetSDKFromContext(ctx); err != nil { + if len(licenseToken) == 0 { + return ctx, nil, nil, err + } + return ctx, &access.GenerationAccess{Allowed: true}, licenseToken, nil + } + + var accessResult *access.GenerationAccess + ctx, err = withPlatformFallback(ctx, func(operationCtx context.Context) error { + var accessErr error + accessResult, accessErr = checkGenerationAccess(operationCtx, args) + return accessErr + }) + if err != nil { + return ctx, nil, nil, err + } + + licenseToken, _ = coreauth.GetLicenseTokenFromContext(ctx) + if len(accessResult.LicenseToken) > 0 { + licenseToken = accessResult.LicenseToken + } + return ctx, accessResult, licenseToken, nil +} + +// withGenerationContext always elects the customer's commercial license: the +// AGPL election exists only as a source-build fallback in the upstream +// generator and is never used by the CLI. An absent token still elects +// commercial; the generator then rejects the generation as unproven rather +// than silently downgrading the license. +func withGenerationContext(ctx context.Context, licenseToken []byte) (context.Context, error) { + ctx, err := coreauth.WithGenerationContext(ctx, generationaccess.GeneratedLicenseCommercial) + if err != nil { + return ctx, err } - return auth.WithGenerationContext(ctx, generatedLicense) + return licensetoken.WithToken(ctx, licenseToken), nil } func ValidateConfig(ctx context.Context, outDir string) error {