-
Notifications
You must be signed in to change notification settings - Fork 39
build: bump openapi-generation to license token validation and wire the license election #2124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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" | ||||||
|
|
@@ -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) | ||||||
| if err != nil { | ||||||
| return nil, nil | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When lint runs interactively without authentication, Prompt for AI agents
Suggested change
|
||||||
| } | ||||||
| ctx = licensetoken.WithToken(commercialCtx, licenseToken) | ||||||
| } | ||||||
|
|
||||||
| // Load the OpenAPI schema | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
|
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) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Suggested change
|
||||||
| if err != nil { | ||||||
| return authCtx, err | ||||||
| } | ||||||
| return authCtx, op(authCtx) | ||||||
| } | ||||||
|
|
||||||
| func authenticateWithHint(ctx context.Context, force bool) (context.Context, error) { | ||||||
| authCtx, err := Authenticate(ctx, force) | ||||||
| if err != nil && config.GetSpeakeasyAPIKey() == "" { | ||||||
| return authCtx, fmt.Errorf("%w. %s", err, licenseHint) | ||||||
| } | ||||||
| return authCtx, err | ||||||
| } | ||||||
|
|
||||||
| func licenseFromContext(ctx context.Context) *license.License { | ||||||
| lic, _ := ctx.Value(licenseContextKey{}).(*license.License) | ||||||
| return lic | ||||||
| } | ||||||
|
|
||||||
| // HasOfflineLicense reports whether ctx was authenticated with the offline | ||||||
| // license rather than the platform. | ||||||
| func HasOfflineLicense(ctx context.Context) bool { | ||||||
| return licenseFromContext(ctx) != nil | ||||||
| } | ||||||
|
|
||||||
| func isAuthenticationFailure(err error) bool { | ||||||
| status := 0 | ||||||
| var sdkErr *sdkerrors.SDKError | ||||||
| var responseErr *sdkerrors.Error | ||||||
| switch { | ||||||
| case errors.As(err, &sdkErr): | ||||||
| status = sdkErr.StatusCode | ||||||
| case errors.As(err, &responseErr): | ||||||
| status = responseErr.StatusCode | ||||||
| } | ||||||
| return status == http.StatusUnauthorized || status == http.StatusForbidden | ||||||
| } | ||||||
|
|
||||||
| func persistableLicenseContext(ctx context.Context, workspaceID string) context.Context { | ||||||
| persisted := []byte(nil) | ||||||
| if token, ok := core.GetLicenseTokenFromContext(ctx); ok { | ||||||
| info, err := licensetoken.Inspect(token) | ||||||
| if err == nil && info.Tier != string(shared.AccountTypeFree) && info.WorkspaceID == workspaceID { | ||||||
| persisted = token | ||||||
| } | ||||||
| } | ||||||
| return context.WithValue(ctx, core.LicenseTokenKey, persisted) | ||||||
| } | ||||||
|
|
||||||
| func UseExistingAPIKeyIfAvailable(ctx context.Context) (context.Context, error) { | ||||||
| existingApiKey := config.GetSpeakeasyAPIKey() | ||||||
| if existingApiKey == "" { | ||||||
|
|
@@ -40,7 +166,7 @@ func UseExistingAPIKeyIfAvailable(ctx context.Context) (context.Context, error) | |||||
| if err != nil { | ||||||
| return ctx, err | ||||||
| } | ||||||
| _ = config.SetSpeakeasyAuthInfo(ctx, core.SpeakeasyAuthInfo{ | ||||||
| _ = config.SetSpeakeasyAuthInfo(persistableLicenseContext(ctx, workspaceID), core.SpeakeasyAuthInfo{ | ||||||
| APIKey: existingApiKey, | ||||||
| WorkspaceID: workspaceID, | ||||||
| }) | ||||||
|
|
||||||
| 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") | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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-runruns without authentication, this branch elects commercial with an empty token. The generator rejects tokenless commercial generation, whilerunDryRunGenerationdiscards its errors, so unauthenticated lint silently loses target-specific warnings; elect AGPL explicitly when no token is available.Prompt for AI agents