Skip to content

Commit ba6ef5c

Browse files
committed
build: bump openapi-generation to license token validation; wire the commercial license election and offline licenses
Bumps openapi-generation to a version that validates commercial license tokens and requires an explicit license election for generated output. The CLI 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 here. A missing license token no longer downgrades the election; generation proceeds as commercial and the generator rejects it as unproven, with a CLI warning pointing at the platform. Lint's diagnostic dry-run reuses the workspace license token under a commercial election and skips diagnostics when unauthenticated. Adds offline licenses (internal/license): a validated license token from SPEAKEASY_LICENSE_TOKEN, SPEAKEASY_LICENSE_FILE, or the persisted offline_license_token authenticates 'run' and 'generate sdk' without the platform, gated per command by the new OfflineCapable field. Offline contexts re-authenticate on demand via EnsureTargets, EnsurePlatform, and WithPlatformFallback, so GitHub runs and uncovered targets still go online. Generation access honors the offline license even when a persisted API key put an SDK client in the context, keeping offline generation offline for logged-in users. Auth persistence captures the license token issued at validation, scoped to the authenticated workspace and never for free tiers. Token-less authentication preserves a manually configured offline license, a workspace change clears it, and logout wipes it. --force on run and generate sdk stays a generation-control flag; refreshing the persisted license online is done explicitly via 'speakeasy auth login', which can again replace a revoked key or switch accounts.
1 parent b42ff17 commit ba6ef5c

17 files changed

Lines changed: 862 additions & 112 deletions

File tree

cmd/generate/sdk.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ type GenerateFlags struct {
3131
}
3232

3333
var genSDKCmd = &model.ExecutableCommand[GenerateFlags]{
34-
Usage: "sdk",
35-
Short: fmt.Sprintf("One-off SDK generation from OpenAPI specs (%s)", strings.Join(GeneratorSupportedTargetNames(), ", ")),
36-
Long: generateLongDesc,
37-
Run: genSDKs,
38-
RequiresAuth: true,
34+
Usage: "sdk",
35+
Short: fmt.Sprintf("One-off SDK generation from OpenAPI specs (%s)", strings.Join(GeneratorSupportedTargetNames(), ", ")),
36+
Long: generateLongDesc,
37+
Run: genSDKs,
38+
RequiresAuth: true,
39+
OfflineCapable: true,
3940
Flags: []flag.Flag{
4041
flag.EnumFlag{
4142
Name: "lang",

cmd/lint/lint.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111

1212
generationaccess "github.com/speakeasy-api/generation-context/access"
1313
"github.com/speakeasy-api/openapi-generation/v2/pkg/generate"
14+
"github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken"
15+
coreauth "github.com/speakeasy-api/speakeasy-core/auth"
1416
"github.com/speakeasy-api/speakeasy-core/openapi"
1517
"github.com/speakeasy-api/speakeasy-core/suggestions"
1618
"github.com/speakeasy-api/speakeasy/internal/arazzo"
@@ -589,10 +591,18 @@ func warningsToTabContents(warnings []error) []interactivity.InspectableContent
589591

590592
// runDryRunGeneration runs a dry-run SDK generation for the specified target and returns warnings
591593
func runDryRunGeneration(ctx context.Context, schemaPath, targetLanguage, workingDir string) ([]error, error) {
592-
// Lint is available without authentication, so its optional diagnostic
593-
// generation must explicitly use the direct AGPL mode when no caller state exists.
594+
// The CLI only generates under the customer's commercial license (the AGPL
595+
// election is a source-build fallback in the upstream generator), so the
596+
// diagnostic dry-run elects commercial with the workspace license token.
597+
// Lint runs without authentication too; in that case there is nothing to
598+
// elect, so the optional dry-run diagnostics are skipped.
594599
if _, ok := generationaccess.StateFromContext(ctx); !ok {
595-
ctx = generationaccess.WithDirect(ctx)
600+
licenseToken, _ := coreauth.GetLicenseTokenFromContext(ctx)
601+
commercialCtx, err := coreauth.WithGenerationContext(ctx, generationaccess.GeneratedLicenseCommercial)
602+
if err != nil {
603+
return nil, nil
604+
}
605+
ctx = licensetoken.WithToken(commercialCtx, licenseToken)
596606
}
597607

598608
// Load the OpenAPI schema

cmd/run.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ var runCmd = &model.ExecutableCommand[RunFlags]{
7878
Run: runNonInteractive,
7979
RunInteractive: runInteractive,
8080
RequiresAuth: true,
81+
OfflineCapable: true,
8182
UsesWorkflowFile: true,
8283
Flags: []flag.Flag{
8384
flag.StringFlag{

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ require (
4141
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
4242
github.com/samber/lo v1.52.0
4343
github.com/sethvargo/go-githubactions v1.3.2
44-
github.com/speakeasy-api/generation-context v1.0.0
44+
github.com/speakeasy-api/generation-context v1.1.0
4545
github.com/speakeasy-api/git-diff-parser v0.2.0
4646
github.com/speakeasy-api/gram v0.0.0-20260121234743-5a36906a8929
4747
github.com/speakeasy-api/huh v1.1.2
4848
github.com/speakeasy-api/jq v0.1.1-0.20251107233444-84d7e49e84a4
4949
github.com/speakeasy-api/openapi v1.25.0
50-
github.com/speakeasy-api/openapi-generation/v2 v2.933.1
50+
github.com/speakeasy-api/openapi-generation/v2 v2.934.0
5151
github.com/speakeasy-api/sdk-gen-config v1.58.0
5252
github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12
5353
github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.28.1

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -530,8 +530,8 @@ github.com/sourcegraph/jsonrpc2 v0.2.2 h1:fCyU80iidEwcF9kWaj4ylOO1h8pT8P8sFGv8EK
530530
github.com/sourcegraph/jsonrpc2 v0.2.2/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
531531
github.com/speakeasy-api/easytemplate v0.12.4 h1:0xEm93tqPfdIWKUwgxvFgDLBmSDXlCImp+mQF3XZcOg=
532532
github.com/speakeasy-api/easytemplate v0.12.4/go.mod h1:UF8bFhTpyr1sHiEWacT7ULN67Bve6UIrTH7uvzdbTEY=
533-
github.com/speakeasy-api/generation-context v1.0.0 h1:LMHn7k1GCT1lIuwWSCn2ogaUgaIq8eGsGGPUXmvpW0s=
534-
github.com/speakeasy-api/generation-context v1.0.0/go.mod h1:AxqOSyH55Kp70pQynKl0nSlpke6P3/FlnFL//7sPM50=
533+
github.com/speakeasy-api/generation-context v1.1.0 h1:VndzBP6wdkk1yp7DGA/lgAMH+qudMLYf2I5wuXscs+8=
534+
github.com/speakeasy-api/generation-context v1.1.0/go.mod h1:AxqOSyH55Kp70pQynKl0nSlpke6P3/FlnFL//7sPM50=
535535
github.com/speakeasy-api/git-diff-parser v0.2.0 h1:fvPsWhTqTt+8j9Kx4eXF6kRRqDUC60gy0VII4PnjIHA=
536536
github.com/speakeasy-api/git-diff-parser v0.2.0/go.mod h1:P46HmmVVmwA9P8h2wa0fDpmRM8/grbVQ+uKhWDtpkIY=
537537
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
550550
github.com/speakeasy-api/libopenapi v0.21.10-fixhiddencomps-fixed/go.mod h1:Gc8oQkjr2InxwumK0zOBtKN9gIlv9L2VmSVIUk2YxcU=
551551
github.com/speakeasy-api/openapi v1.25.0 h1:xyJ5ZzW4YwStT2ICo0o7v9M890J7VpsR7AqhQSZnwl4=
552552
github.com/speakeasy-api/openapi v1.25.0/go.mod h1:9gGkzi9jNspEbcB08zta+IjzAi5Zy4QgSEQfF/LSrbQ=
553-
github.com/speakeasy-api/openapi-generation/v2 v2.933.1 h1:g0lZi8XsS2NjArwP1hyC08bJRKeOHjgD9gx9ie7KONQ=
554-
github.com/speakeasy-api/openapi-generation/v2 v2.933.1/go.mod h1:1Yypyh8Dl2dg/aYMY8ySCJ6kfuI9xTpUxuSs30XpZPE=
553+
github.com/speakeasy-api/openapi-generation/v2 v2.934.0 h1:bF3+0Lw4w5WMRE/anhdpUfLTmzOhGAYM7ccLs0VF0Lk=
554+
github.com/speakeasy-api/openapi-generation/v2 v2.934.0/go.mod h1:OOjYkuR25Q5RvpwR5z1EeA9tIKdp3TK4jnXQsNbpmHg=
555555
github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4 h1:gV+lYeVNNJG9X3Sl9Su3cRh1iF/oNqzvb5Ijq2QR8jY=
556556
github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4/go.mod h1:1zQpVio7X6QJDtyNdUguCgZ+IC7CzKhhjvNgJdvGVF0=
557557
github.com/speakeasy-api/sdk-gen-config v1.58.0 h1:JrDgDU3XBIidv+TXFqYBvIomfeGEQ0zN+OnHyUc+kNw=

internal/auth/auth.go

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,157 @@ package auth
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net/http"
78
"os"
89

10+
"github.com/speakeasy-api/openapi-generation/v2/pkg/licensetoken"
911
"github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/operations"
12+
"github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/sdkerrors"
13+
"github.com/speakeasy-api/speakeasy-client-sdk-go/v3/pkg/models/shared"
1014
core "github.com/speakeasy-api/speakeasy-core/auth"
1115
"github.com/speakeasy-api/speakeasy/internal/config"
1216
"github.com/speakeasy-api/speakeasy/internal/interactivity"
17+
"github.com/speakeasy-api/speakeasy/internal/license"
1318
"github.com/speakeasy-api/speakeasy/internal/log"
1419
"github.com/speakeasy-api/speakeasy/internal/sdk"
1520
)
1621

22+
type licenseContextKey struct{}
23+
24+
const licenseHint = "For offline authentication, configure offline_license_token or set SPEAKEASY_LICENSE_TOKEN or SPEAKEASY_LICENSE_FILE"
25+
26+
type coreAuthenticateFunc func(context.Context, string, bool) (context.Context, core.SpeakeasyAuthInfo, error)
27+
type persistAuthInfoFunc func(context.Context, core.SpeakeasyAuthInfo) error
28+
type authenticateWithHintFunc func(context.Context, bool) (context.Context, error)
29+
1730
func Authenticate(ctx context.Context, force bool) (context.Context, error) {
18-
existingKey := config.GetSpeakeasyAPIKey()
19-
authCtx, res, err := core.Authenticate(ctx, existingKey, force)
31+
return authenticate(ctx, config.GetSpeakeasyAPIKey(), force, core.Authenticate, persistAuthInfo)
32+
}
33+
34+
func authenticate(ctx context.Context, apiKey string, force bool, authenticateCore coreAuthenticateFunc, persist persistAuthInfoFunc) (context.Context, error) {
35+
ctx = context.WithValue(ctx, licenseContextKey{}, (*license.License)(nil))
36+
ctx = context.WithValue(ctx, core.LicenseTokenKey, []byte(nil))
37+
38+
// force ignores the existing API key and opens the browser, letting callers
39+
// such as `speakeasy auth login` replace a revoked key or switch accounts.
40+
authCtx, res, err := authenticateCore(ctx, apiKey, force)
2041
if err != nil {
2142
return authCtx, err
2243
}
23-
if err := config.SetSpeakeasyAuthInfo(authCtx, res); err != nil {
44+
if err := persist(authCtx, res); err != nil {
2445
return authCtx, fmt.Errorf("failed to save API key: %w", err)
2546
}
26-
2747
return authCtx, nil
2848
}
2949

50+
func persistAuthInfo(ctx context.Context, info core.SpeakeasyAuthInfo) error {
51+
return config.SetSpeakeasyAuthInfo(persistableLicenseContext(ctx, info.WorkspaceID), info)
52+
}
53+
54+
// CommandContext authenticates with the stored offline license when it is usable and with the platform otherwise.
55+
// `speakeasy auth login` is the explicit way to bypass the offline license and refresh the persisted license online.
56+
func CommandContext(ctx context.Context) (context.Context, error) {
57+
return commandContext(ctx, authenticateWithHint)
58+
}
59+
60+
func commandContext(ctx context.Context, authenticateOnline authenticateWithHintFunc) (context.Context, error) {
61+
lic, warning := license.Resolve(os.Getenv, config.GetOfflineLicenseToken(), config.GetWorkspaceID())
62+
if warning != "" {
63+
log.From(ctx).Warn(warning)
64+
}
65+
if lic != nil {
66+
licenseCtx, err := license.ContextFromLicense(ctx, lic, config.GetSpeakeasyAPIKey())
67+
if err == nil {
68+
return context.WithValue(licenseCtx, licenseContextKey{}, lic), nil
69+
}
70+
log.From(ctx).Warn("Could not use the stored offline license; falling back to platform authentication")
71+
}
72+
return authenticateOnline(ctx, false)
73+
}
74+
75+
// EnsureTargets re-authenticates online when the offline license does not cover every target.
76+
func EnsureTargets(ctx context.Context, targets []string) (context.Context, error) {
77+
lic := licenseFromContext(ctx)
78+
if lic == nil {
79+
return ctx, nil
80+
}
81+
for _, target := range targets {
82+
if !lic.Info.Covers(target) {
83+
return authenticateWithHint(ctx, false)
84+
}
85+
}
86+
return ctx, nil
87+
}
88+
89+
// EnsurePlatform re-authenticates online when an offline-license context has no SDK client.
90+
func EnsurePlatform(ctx context.Context) (context.Context, error) {
91+
if licenseFromContext(ctx) == nil {
92+
return ctx, nil
93+
}
94+
if _, err := core.GetSDKFromContext(ctx); err == nil {
95+
return ctx, nil
96+
}
97+
return authenticateWithHint(ctx, false)
98+
}
99+
100+
// WithPlatformFallback runs op and, when an offline-license context is rejected by the platform, re-authenticates and retries once.
101+
func WithPlatformFallback(ctx context.Context, op func(context.Context) error) (context.Context, error) {
102+
err := op(ctx)
103+
if err == nil || licenseFromContext(ctx) == nil || !isAuthenticationFailure(err) {
104+
return ctx, err
105+
}
106+
authCtx, err := authenticateWithHint(ctx, true)
107+
if err != nil {
108+
return authCtx, err
109+
}
110+
return authCtx, op(authCtx)
111+
}
112+
113+
func authenticateWithHint(ctx context.Context, force bool) (context.Context, error) {
114+
authCtx, err := Authenticate(ctx, force)
115+
if err != nil && config.GetSpeakeasyAPIKey() == "" {
116+
return authCtx, fmt.Errorf("%w. %s", err, licenseHint)
117+
}
118+
return authCtx, err
119+
}
120+
121+
func licenseFromContext(ctx context.Context) *license.License {
122+
lic, _ := ctx.Value(licenseContextKey{}).(*license.License)
123+
return lic
124+
}
125+
126+
// HasOfflineLicense reports whether ctx was authenticated with the offline
127+
// license rather than the platform.
128+
func HasOfflineLicense(ctx context.Context) bool {
129+
return licenseFromContext(ctx) != nil
130+
}
131+
132+
func isAuthenticationFailure(err error) bool {
133+
status := 0
134+
var sdkErr *sdkerrors.SDKError
135+
var responseErr *sdkerrors.Error
136+
switch {
137+
case errors.As(err, &sdkErr):
138+
status = sdkErr.StatusCode
139+
case errors.As(err, &responseErr):
140+
status = responseErr.StatusCode
141+
}
142+
return status == http.StatusUnauthorized || status == http.StatusForbidden
143+
}
144+
145+
func persistableLicenseContext(ctx context.Context, workspaceID string) context.Context {
146+
persisted := []byte(nil)
147+
if token, ok := core.GetLicenseTokenFromContext(ctx); ok {
148+
info, err := licensetoken.Inspect(token)
149+
if err == nil && info.Tier != string(shared.AccountTypeFree) && info.WorkspaceID == workspaceID {
150+
persisted = token
151+
}
152+
}
153+
return context.WithValue(ctx, core.LicenseTokenKey, persisted)
154+
}
155+
30156
func UseExistingAPIKeyIfAvailable(ctx context.Context) (context.Context, error) {
31157
existingApiKey := config.GetSpeakeasyAPIKey()
32158
if existingApiKey == "" {
@@ -40,7 +166,7 @@ func UseExistingAPIKeyIfAvailable(ctx context.Context) (context.Context, error)
40166
if err != nil {
41167
return ctx, err
42168
}
43-
_ = config.SetSpeakeasyAuthInfo(ctx, core.SpeakeasyAuthInfo{
169+
_ = config.SetSpeakeasyAuthInfo(persistableLicenseContext(ctx, workspaceID), core.SpeakeasyAuthInfo{
44170
APIKey: existingApiKey,
45171
WorkspaceID: workspaceID,
46172
})

internal/auth/auth_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"slices"
6+
"testing"
7+
8+
core "github.com/speakeasy-api/speakeasy-core/auth"
9+
"github.com/speakeasy-api/speakeasy/internal/license"
10+
)
11+
12+
func TestAuthenticateForceIgnoresExistingAPIKey(t *testing.T) {
13+
t.Parallel()
14+
15+
ctx := context.WithValue(context.Background(), licenseContextKey{}, &license.License{})
16+
ctx = context.WithValue(ctx, core.LicenseTokenKey, []byte("stale-license"))
17+
freshLicense := []byte("fresh-license")
18+
coreForce := true
19+
persisted := false
20+
21+
authCtx, err := authenticate(
22+
ctx,
23+
"api-key",
24+
true,
25+
func(ctx context.Context, apiKey string, force bool) (context.Context, core.SpeakeasyAuthInfo, error) {
26+
if apiKey != "api-key" {
27+
t.Fatalf("API key = %q, want api-key", apiKey)
28+
}
29+
if licenseFromContext(ctx) != nil {
30+
t.Fatal("offline license reached core authentication")
31+
}
32+
if token, ok := core.GetLicenseTokenFromContext(ctx); ok || len(token) != 0 {
33+
t.Fatalf("stale license reached core authentication: %q", token)
34+
}
35+
coreForce = force
36+
return context.WithValue(ctx, core.LicenseTokenKey, freshLicense), core.SpeakeasyAuthInfo{
37+
APIKey: apiKey,
38+
WorkspaceID: "workspace",
39+
}, nil
40+
},
41+
func(ctx context.Context, info core.SpeakeasyAuthInfo) error {
42+
persisted = true
43+
if info.APIKey != "api-key" || info.WorkspaceID != "workspace" {
44+
t.Fatalf("persisted auth info = %#v", info)
45+
}
46+
token, ok := core.GetLicenseTokenFromContext(ctx)
47+
if !ok || !slices.Equal(token, freshLicense) {
48+
t.Fatalf("persisted license = %q, want %q", token, freshLicense)
49+
}
50+
return nil
51+
},
52+
)
53+
if err != nil {
54+
t.Fatalf("authenticate: %v", err)
55+
}
56+
if !coreForce {
57+
t.Fatal("force did not ignore the existing API key for browser authentication")
58+
}
59+
if !persisted {
60+
t.Fatal("refreshed authentication was not persisted")
61+
}
62+
if token, ok := core.GetLicenseTokenFromContext(authCtx); !ok || !slices.Equal(token, freshLicense) {
63+
t.Fatalf("authentication context license = %q, want %q", token, freshLicense)
64+
}
65+
}
66+
67+
func TestAuthenticateForceUsesBrowserWithoutAPIKey(t *testing.T) {
68+
t.Parallel()
69+
70+
coreForce := false
71+
_, err := authenticate(
72+
context.Background(),
73+
"",
74+
true,
75+
func(ctx context.Context, _ string, force bool) (context.Context, core.SpeakeasyAuthInfo, error) {
76+
coreForce = force
77+
return ctx, core.SpeakeasyAuthInfo{}, nil
78+
},
79+
func(context.Context, core.SpeakeasyAuthInfo) error { return nil },
80+
)
81+
if err != nil {
82+
t.Fatalf("authenticate: %v", err)
83+
}
84+
if !coreForce {
85+
t.Fatal("force did not request browser authentication without an API key")
86+
}
87+
}
88+
89+
func TestCommandContextFallsBackToPlatformWithoutOfflineLicense(t *testing.T) {
90+
// An unusable env token keeps license resolution deterministic regardless of
91+
// any offline license persisted in the developer's real CLI config.
92+
t.Setenv("SPEAKEASY_LICENSE_TOKEN", "not-a-license")
93+
94+
wantCtx := context.WithValue(context.Background(), core.WorkspaceIDKey, "online-workspace")
95+
called := false
96+
ctx, err := commandContext(context.Background(), func(_ context.Context, force bool) (context.Context, error) {
97+
called = true
98+
if force {
99+
t.Fatal("command context forced online re-authentication")
100+
}
101+
return wantCtx, nil
102+
})
103+
if err != nil {
104+
t.Fatalf("command context: %v", err)
105+
}
106+
if !called || ctx != wantCtx {
107+
t.Fatal("command context did not fall back to platform authentication")
108+
}
109+
}

0 commit comments

Comments
 (0)