From 7efe47a874a9146e7b0af6a8c18fdc17bd3d0bf2 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 1 Sep 2026 11:01:06 +0100 Subject: [PATCH 1/2] feat: offline licenses authenticate the CLI without the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds offline license support: a stored license token (SPEAKEASY_LICENSE_TOKEN, SPEAKEASY_LICENSE_FILE, or offline_license_token in the CLI config) authenticates offline-capable commands (run, generate sdk) without calling /v1/auth/validate. With a configured API key the context keeps an SDK client so registry uploads, telemetry, and workspace confirmation behave as online; without one the run is fully offline. Online re-authentication happens when the license does not cover a selected target (EnsureTargets) and when a platform API needs an SDK client — GitHub workflow runs and workflows that hard-require the registry: frozen lockfile runs, registry inputs or overlays, and blocking code-samples registry output (EnsurePlatform). Best-effort registry interactions still skip offline. Headless sessions without an API key fail fast with the offline-license hint instead of opening a browser. Generation access for an offline-license context is decided by the signed token (expiry and target coverage, validated by the generator) instead of the platform access check. The offline license is ignored when an API key is configured but its workspace is not yet known, so an env token cannot bind a different workspace's identity to the key. Successful online authentication persists the issued token (non-free tier, matching workspace) — except inside GitHub Actions, where a persisted token would flip the same job's later commands to offline auth and bypass the platform access check. Tokenless re-auth in the same workspace preserves the stored token; switching workspaces clears it with a notice; logging out clears it. Authentication into the speakeasy-self default workspace remains a no-op, preserving the stored customer id. --- cmd/generate/sdk.go | 11 +- cmd/run.go | 1 + internal/auth/auth.go | 125 +++++++++++++++++- internal/auth/auth_test.go | 107 +++++++++++++++ internal/config/config.go | 18 ++- internal/config/config_test.go | 72 ++++++++++ internal/license/license.go | 153 +++++++++++++++++++++ internal/license/license_test.go | 220 +++++++++++++++++++++++++++++++ internal/model/command.go | 22 +++- internal/run/github.go | 9 ++ internal/run/prepare_test.go | 103 +++++++++++++++ internal/run/run.go | 118 ++++++++++++++++- internal/sdkgen/sdkgen.go | 52 ++++++-- 13 files changed, 980 insertions(+), 31 deletions(-) create mode 100644 internal/auth/auth_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/license/license.go create mode 100644 internal/license/license_test.go create mode 100644 internal/run/prepare_test.go 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/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/internal/auth/auth.go b/internal/auth/auth.go index 9f9960440..0054345b0 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -6,27 +6,142 @@ import ( "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/shared" core "github.com/speakeasy-api/speakeasy-core/auth" "github.com/speakeasy-api/speakeasy/internal/config" + "github.com/speakeasy-api/speakeasy/internal/env" "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" + "github.com/speakeasy-api/speakeasy/internal/utils" ) +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. + 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) + } + // With no persisted workspace there is no proof the license belongs to the + // configured API key's workspace; authenticate online once to establish it. + if lic != nil && config.GetSpeakeasyAPIKey() != "" && config.GetWorkspaceID() == "" { + log.From(ctx).Warn("Ignoring the offline license: the configured API key's workspace is not known yet; authenticating online") + lic = nil + } + 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) +} + +func authenticateWithHint(ctx context.Context, force bool) (context.Context, error) { + // Without an API key the only online path is a browser login, which would + // hang a headless session; fail fast with the offline-license hint instead. + if config.GetSpeakeasyAPIKey() == "" && !utils.IsInteractive() { + return ctx, fmt.Errorf("authentication required but no API key is configured in a non-interactive session. %s", licenseHint) + } + 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 persistableLicenseContext(ctx context.Context, workspaceID string) context.Context { + persisted := []byte(nil) + // A token persisted inside a GitHub Actions container would flip the same + // job's later commands to offline auth and bypass the platform access + // check; the container is ephemeral, so nothing is gained by storing it. + if !env.IsGithubAction() { + 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 +155,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..1bec8ef2a --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,107 @@ +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) { + 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..537d2406c 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,7 +107,18 @@ 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") + if defaultWorkspaceID != "self" { + // Only replace the stored offline license when this authentication + // issued one; 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 { + if vCfg.GetString("offline_license_token") != "" { + println(styles.DimmedItalic.Render("Clearing the offline license stored for the previous workspace")) + } + 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) @@ -128,6 +143,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..4a13544d9 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,72 @@ +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) + } +} + +func TestSetSpeakeasyAuthInfoSelfWorkspaceIsNoOp(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", "self") + vCfg.Set("speakeasy_customer_id", "customer-id") + vCfg.Set("offline_license_token", "stored-token") + + info := core.SpeakeasyAuthInfo{APIKey: "api-key", WorkspaceID: "self"} + if err := SetSpeakeasyAuthInfo(context.Background(), info); err != nil { + t.Fatalf("set auth info for self: %v", err) + } + if got := vCfg.GetString("speakeasy_customer_id"); got != "customer-id" { + t.Fatalf("self re-authentication blanked the stored customer id: %q", got) + } + if got := GetOfflineLicenseToken(); got != "stored-token" { + t.Fatalf("self re-authentication changed the stored 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..f343e495e 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,11 @@ 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 + // unconditionally call platform APIs must leave this unset. + 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 +142,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/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/prepare_test.go b/internal/run/prepare_test.go new file mode 100644 index 000000000..eb4db7c59 --- /dev/null +++ b/internal/run/prepare_test.go @@ -0,0 +1,103 @@ +package run + +import ( + "context" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" +) + +func TestPrepareWorkflowContextEnsuresPlatformForRegistryWorkflows(t *testing.T) { //nolint:paralleltest + origTargets, origPlatform := ensureTargets, ensurePlatform + t.Cleanup(func() { ensureTargets, ensurePlatform = origTargets, origPlatform }) + ensureTargets = func(ctx context.Context, _ []string) (context.Context, error) { return ctx, nil } + + blockingFalse := false + registryInputSource := workflow.Source{ + Inputs: []workflow.Document{{Location: "registry.speakeasyapi.dev/org/ws/ns"}}, + } + localSource := workflow.Source{ + Inputs: []workflow.Document{{Location: "openapi.yaml"}}, + } + + tests := []struct { + name string + workflow *Workflow + wantPlatform bool + }{ + { + name: "frozen workflow lock", + workflow: &Workflow{FrozenWorkflowLock: true}, + wantPlatform: true, + }, + { + name: "selected registry input source", + workflow: &Workflow{Source: "s", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": registryInputSource}, + }}, + wantPlatform: true, + }, + { + name: "selected target with a registry input source", + workflow: &Workflow{Target: "t", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": registryInputSource}, + Targets: map[string]workflow.Target{"t": {Target: "go", Source: "s"}}, + }}, + wantPlatform: true, + }, + { + name: "registry overlay", + workflow: &Workflow{Source: "s", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": {Overlays: []workflow.Overlay{{Document: &workflow.Document{Location: "registry.speakeasyapi.dev/org/ws/ns"}}}}}, + }}, + wantPlatform: true, + }, + { + name: "blocking code samples registry", + workflow: &Workflow{Target: "t", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": localSource}, + Targets: map[string]workflow.Target{"t": {Target: "go", Source: "s", CodeSamples: &workflow.CodeSamples{Registry: &workflow.SourceRegistry{}}}}, + }}, + wantPlatform: true, + }, + { + name: "non-blocking code samples registry", + workflow: &Workflow{Target: "t", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": localSource}, + Targets: map[string]workflow.Target{"t": {Target: "go", Source: "s", CodeSamples: &workflow.CodeSamples{Registry: &workflow.SourceRegistry{}, Blocking: &blockingFalse}}}, + }}, + wantPlatform: false, + }, + { + name: "best-effort source publishing", + workflow: &Workflow{Target: "t", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": {Inputs: localSource.Inputs, Registry: &workflow.SourceRegistry{}}}, + Targets: map[string]workflow.Target{"t": {Target: "go", Source: "s"}}, + }}, + wantPlatform: false, + }, + { + name: "unselected registry source", + workflow: &Workflow{Target: "t", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": localSource, "other": registryInputSource}, + Targets: map[string]workflow.Target{"t": {Target: "go", Source: "s"}}, + }}, + wantPlatform: false, + }, + } + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { + called := false + ensurePlatform = func(ctx context.Context) (context.Context, error) { + called = true + return ctx, nil + } + if _, err := tt.workflow.prepareWorkflowContext(context.Background()); err != nil { + t.Fatalf("prepareWorkflowContext: %v", err) + } + if called != tt.wantPlatform { + t.Fatalf("ensurePlatform called = %v, want %v", called, tt.wantPlatform) + } + }) + } +} diff --git a/internal/run/run.go b/internal/run/run.go index 8b8efe341..112aa2f78 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -19,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" @@ -29,6 +30,11 @@ import ( const ErrNoRollback = errors.Error("failed with error that shouldn't be rolled back") +var ( + ensureTargets = cliauth.EnsureTargets + ensurePlatform = cliauth.EnsurePlatform +) + type SourceStep interface { Do(ctx context.Context, inputPath string) (string, error) } @@ -128,6 +134,11 @@ func (w *Workflow) PrintSuccessSummary(ctx context.Context) { } func (w *Workflow) Run(ctx context.Context) error { + ctx, workflowContextErr := w.prepareWorkflowContext(ctx) + if workflowContextErr != nil { + return fmt.Errorf("failed to prepare workflow context: %w", workflowContextErr) + } + startTime := time.Now() err := w.RunInner(ctx) w.Error = err @@ -167,20 +178,118 @@ func (w *Workflow) Run(ctx context.Context) error { return err } -func (w *Workflow) RunInner(ctx context.Context) error { - if w.Source != "" && w.Target != "" { - return fmt.Errorf("cannot specify both a target and a source") +func (w *Workflow) prepareWorkflowContext(ctx context.Context) (context.Context, error) { + targetTypes, err := w.selectedTargetTypes() + if err != nil { + return ctx, err + } + ctx, err = ensureTargets(ctx, targetTypes) + if err != nil { + return ctx, err + } + if w.requiresRegistry() { + ctx, err = ensurePlatform(ctx) + if err != nil { + return ctx, err + } } + return ctx, nil +} +// requiresRegistry covers hard registry dependencies of the selected sources +// and targets only; best-effort interactions (source tracking, change reports, +// source publishing) skip when the registry is disabled. +func (w *Workflow) requiresRegistry() bool { + if w.FrozenWorkflowLock { + return true + } + targetIDs := []string{w.Target} + if w.Target == "all" { + targetIDs = lo.Keys(w.workflow.Targets) + } sourceIDs := []string{w.Source} if w.Source == "all" { sourceIDs = lo.Keys(w.workflow.Sources) } + for _, targetID := range targetIDs { + if targetID == "" { + continue + } + target, ok := w.workflow.Targets[targetID] + if !ok { + continue + } + if target.CodeSamples != nil && target.CodeSamples.Registry != nil && + (target.CodeSamples.Blocking == nil || *target.CodeSamples.Blocking) { + return true + } + sourceIDs = append(sourceIDs, target.Source) + } + seenSources := make(map[string]struct{}, len(sourceIDs)) + for _, sourceID := range sourceIDs { + if sourceID == "" { + continue + } + if _, ok := seenSources[sourceID]; ok { + continue + } + seenSources[sourceID] = struct{}{} + source, ok := w.workflow.Sources[sourceID] + if !ok { + continue + } + for _, input := range source.Inputs { + if input.IsSpeakeasyRegistry() { + return true + } + } + for _, overlay := range source.Overlays { + if overlay.Document != nil && overlay.Document.IsSpeakeasyRegistry() { + return true + } + } + } + return false +} + +func (w *Workflow) selectedTargetTypes() ([]string, error) { + if w.Source != "" && w.Target != "" { + 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) + } + targetIDs := []string{w.Target} + 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 ") } @@ -206,9 +315,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/sdkgen.go b/internal/sdkgen/sdkgen.go index ce1845504..6006099d3 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" @@ -39,6 +40,12 @@ import ( // It defaults to the real prompt implementation. var PromptForCustomCode = prompts.PromptForCustomCode +var ( + checkGenerationAccess = access.CheckGenerationAccess + ensureTargets = cliauth.EnsureTargets + hasOfflineLicense = cliauth.HasOfflineLicense +) + type GenerationAccess struct { AccessAllowed bool Message string @@ -103,15 +110,14 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err logger := log.From(ctx).WithAssociatedFile(opts.SchemaPath) - accessResult, accessErr := access.CheckGenerationAccess(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 - licenseToken, _ := auth.GetLicenseTokenFromContext(ctx) if !generationAccess && level != nil && *level == shared.LevelBlocked { msg := styles.RenderErrorMessage( @@ -164,7 +170,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)), @@ -322,10 +328,10 @@ func Generate(ctx context.Context, opts GenerateOptions) (*GenerationAccess, err sdkDocsLink := "https://www.speakeasy.com/docs/customize-sdks" cliEvent := events.GetTelemetryEventFromContext(ctx) - if cliEvent != nil && cliEvent.ExecutionID != "" { + if cliEvent != nil && cliEvent.ExecutionID != "" && !coreauth.IsTelemetryDisabled(ctx) { // 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) @@ -359,15 +365,41 @@ func WithCommercialGenerationContext(ctx context.Context) (context.Context, erro if _, ok := generationaccess.StateFromContext(ctx); ok { return ctx, nil } - licenseToken, _ := auth.GetLicenseTokenFromContext(ctx) + licenseToken, _ := coreauth.GetLicenseTokenFromContext(ctx) return withGenerationContext(ctx, licenseToken) } +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 may still carry an SDK client; the license, + // not the platform access check, decides generation access. + 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 + } + + accessResult, err := checkGenerationAccess(ctx, args) + if err != nil { + return ctx, nil, nil, err + } + return ctx, accessResult, licenseToken, nil +} + // withGenerationContext elects the commercial license — the AGPL election is a // source-build fallback in the generator, never used by the CLI. An absent // token fails generator validation as unproven rather than downgrading. func withGenerationContext(ctx context.Context, licenseToken []byte) (context.Context, error) { - ctx, err := auth.WithGenerationContext(ctx, generationaccess.GeneratedLicenseCommercial) + ctx, err := coreauth.WithGenerationContext(ctx, generationaccess.GeneratedLicenseCommercial) if err != nil { return ctx, err } From e1b1f32508a53ad3e636e5cbc4e981a8120469cf Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 1 Sep 2026 13:35:11 +0100 Subject: [PATCH 2/2] fix: address review findings on the offline license path The offline-context SDK client keeps the SDK's default bounded HTTP client, matching the client core auth stores after online authentication. An env-supplied license with an unknown API-key workspace is honored with a warning instead of forcing an online round-trip air-gapped environments cannot make; only config-stored licenses require the workspace to be established first. requiresRegistry follows source references transitively and skips inputs replaced by a --source-location override. --- internal/auth/auth.go | 15 +++++++++++---- internal/license/license.go | 9 ++++++--- internal/run/prepare_test.go | 17 +++++++++++++++++ internal/run/run.go | 17 +++++++++++++---- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0054345b0..b8e1e5b8e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -61,11 +61,18 @@ func commandContext(ctx context.Context, authenticateOnline authenticateWithHint if warning != "" { log.From(ctx).Warn(warning) } - // With no persisted workspace there is no proof the license belongs to the - // configured API key's workspace; authenticate online once to establish it. + // With no persisted workspace there is no proof a config-stored license + // belongs to the configured API key's workspace; authenticate online once + // to establish it. An env-supplied license is an explicit choice and is + // honored (air-gapped environments cannot go online), with a warning that + // the pairing is unverified. if lic != nil && config.GetSpeakeasyAPIKey() != "" && config.GetWorkspaceID() == "" { - log.From(ctx).Warn("Ignoring the offline license: the configured API key's workspace is not known yet; authenticating online") - lic = nil + if lic.Source == "" { + log.From(ctx).Warn("Ignoring the stored offline license: the configured API key's workspace is not known yet; authenticating online") + lic = nil + } else { + log.From(ctx).Warn(fmt.Sprintf("Using %s for workspace %s; unable to verify it matches the configured API key's workspace", lic.Source, lic.Info.WorkspaceSlug)) + } } if lic != nil { licenseCtx, err := license.ContextFromLicense(ctx, lic, config.GetSpeakeasyAPIKey()) diff --git a/internal/license/license.go b/internal/license/license.go index d9d3d08e3..4d21d704f 100644 --- a/internal/license/license.go +++ b/internal/license/license.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "net/http" "os" "strings" "time" @@ -26,6 +25,9 @@ const ( type License struct { Token []byte Info licensetoken.TokenInfo + // Source is the environment variable the token was resolved from; empty + // when it came from the CLI config. + Source string } var inspect = licensetoken.Inspect @@ -36,7 +38,7 @@ func Resolve(getenv func(string) string, configToken string, workspaceID string) token = []byte(strings.TrimSpace(string(token))) info, err := inspect(token) if err == nil && usable(info, workspaceID) { - return &License{Token: token, Info: info}, "" + return &License{Token: token, Info: info, Source: source}, "" } if source == "" { return nil, "" @@ -119,11 +121,12 @@ func ContextFromLicense(ctx context.Context, lic *License, apiKey string) (conte if apiKey != "" { security := shared.Security{APIKey: &apiKey} + // The SDK's default HTTP client keeps its built-in timeout, matching + // the client core auth stores after an online authentication. 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) } diff --git a/internal/run/prepare_test.go b/internal/run/prepare_test.go index eb4db7c59..e0a1df31c 100644 --- a/internal/run/prepare_test.go +++ b/internal/run/prepare_test.go @@ -84,6 +84,23 @@ func TestPrepareWorkflowContextEnsuresPlatformForRegistryWorkflows(t *testing.T) }}, wantPlatform: false, }, + { + name: "transitive source reference to a registry source", + workflow: &Workflow{Source: "top", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{ + "top": {Inputs: []workflow.Document{{Location: "source:base"}}}, + "base": registryInputSource, + }, + }}, + wantPlatform: true, + }, + { + name: "source location override replaces registry inputs", + workflow: &Workflow{Source: "s", SourceLocation: "local-openapi.yaml", workflow: workflow.Workflow{ + Sources: map[string]workflow.Source{"s": registryInputSource}, + }}, + wantPlatform: false, + }, } for _, tt := range tests { //nolint:paralleltest t.Run(tt.name, func(t *testing.T) { diff --git a/internal/run/run.go b/internal/run/run.go index 112aa2f78..0b01c7cb4 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -226,7 +226,8 @@ func (w *Workflow) requiresRegistry() bool { sourceIDs = append(sourceIDs, target.Source) } seenSources := make(map[string]struct{}, len(sourceIDs)) - for _, sourceID := range sourceIDs { + for i := 0; i < len(sourceIDs); i++ { + sourceID := sourceIDs[i] if sourceID == "" { continue } @@ -238,9 +239,17 @@ func (w *Workflow) requiresRegistry() bool { if !ok { continue } - for _, input := range source.Inputs { - if input.IsSpeakeasyRegistry() { - return true + // A --source-location override replaces the source's inputs, so they + // impose no registry requirement; overlays still apply. + if w.SourceLocation == "" { + for _, input := range source.Inputs { + if input.IsSourceRef() { + sourceIDs = append(sourceIDs, input.SourceRefName()) + continue + } + if input.IsSpeakeasyRegistry() { + return true + } } } for _, overlay := range source.Overlays {