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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions cmd/generate/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 13 additions & 3 deletions cmd/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

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"
Expand Down Expand Up @@ -589,10 +591,18 @@

// 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)

@cubic-dev-ai cubic-dev-ai Bot Aug 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When lint openapi --dry-run runs without authentication, this branch elects commercial with an empty token. The generator rejects tokenless commercial generation, while runDryRunGeneration discards its errors, so unauthenticated lint silently loses target-specific warnings; elect AGPL explicitly when no token is available.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/lint/lint.go, line 601:

<comment>When `lint openapi --dry-run` runs without authentication, this branch elects commercial with an empty token. The generator rejects tokenless commercial generation, while `runDryRunGeneration` discards its errors, so unauthenticated lint silently loses target-specific warnings; elect AGPL explicitly when no token is available.</comment>

<file context>
@@ -589,10 +591,18 @@ func warningsToTabContents(warnings []error) []interactivity.InspectableContent
 	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
</file context>
Fix with cubic

if err != nil {
return nil, nil

Check failure on line 603 in cmd/lint/lint.go

View workflow job for this annotation

GitHub Actions / golangci-lint

error is not nil (line 601) but it returns nil (nilerr)

@cubic-dev-ai cubic-dev-ai Bot Aug 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When lint runs interactively without authentication, WithGenerationContext fails and this returns a nil error, so the caller displays an empty generation-warnings tab as if the dry run succeeded. Return the error here; the callers already skip targets whose dry run returns an error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/lint/lint.go, line 603:

<comment>When lint runs interactively without authentication, `WithGenerationContext` fails and this returns a nil error, so the caller displays an empty generation-warnings tab as if the dry run succeeded. Return the error here; the callers already skip targets whose dry run returns an error.</comment>

<file context>
@@ -589,10 +591,18 @@ func warningsToTabContents(warnings []error) []interactivity.InspectableContent
+		licenseToken, _ := coreauth.GetLicenseTokenFromContext(ctx)
+		commercialCtx, err := coreauth.WithGenerationContext(ctx, generationaccess.GeneratedLicenseCommercial)
+		if err != nil {
+			return nil, nil
+		}
+		ctx = licensetoken.WithToken(commercialCtx, licenseToken)
</file context>
Suggested change
return nil, nil
return nil, err
Fix with cubic

}
ctx = licensetoken.WithToken(commercialCtx, licenseToken)
}

// Load the OpenAPI schema
Expand Down
1 change: 1 addition & 0 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ var runCmd = &model.ExecutableCommand[RunFlags]{
Run: runNonInteractive,
RunInteractive: runInteractive,
RequiresAuth: true,
OfflineCapable: true,
UsesWorkflowFile: true,
Flags: []flag.Flag{
flag.StringFlag{
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
136 changes: 131 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
ThomasRooney marked this conversation as resolved.
}
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)

@cubic-dev-ai cubic-dev-ai Bot Aug 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an offline-license access check returns 401/403, this fallback passes force=true, which opens the browser instead of refreshing through the configured API key. Automatic retries therefore fail in CI and other non-interactive environments; use a non-interactive online refresh path here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/auth/auth.go, line 106:

<comment>When an offline-license access check returns 401/403, this fallback passes `force=true`, which opens the browser instead of refreshing through the configured API key. Automatic retries therefore fail in CI and other non-interactive environments; use a non-interactive online refresh path here.</comment>

<file context>
@@ -2,31 +2,157 @@ package auth
+	if err == nil || licenseFromContext(ctx) == nil || !isAuthenticationFailure(err) {
+		return ctx, err
+	}
+	authCtx, err := authenticateWithHint(ctx, true)
+	if err != nil {
+		return authCtx, err
</file context>
Suggested change
authCtx, err := authenticateWithHint(ctx, true)
authCtx, err := authenticateWithHint(ctx, false)
Fix with cubic

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 == "" {
Expand All @@ -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,
})
Expand Down
109 changes: 109 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading