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
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
132 changes: 127 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,149 @@ 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 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() == "" {
Comment thread
ThomasRooney marked this conversation as resolved.
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())
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 == "" {
Expand All @@ -40,7 +162,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
107 changes: 107 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
ThomasRooney marked this conversation as resolved.

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")
}
}
18 changes: 17 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}

Expand Down
Loading
Loading