Skip to content

feat: offline licenses authenticate the CLI without the platform - #2127

Open
ThomasRooney wants to merge 2 commits into
mainfrom
feat/offline-license-auth
Open

ThomasRooney wants to merge 2 commits into
mainfrom
feat/offline-license-auth

Conversation

@ThomasRooney

@ThomasRooney ThomasRooney commented Sep 1, 2026 •

Copy link
Copy Markdown
Member

Why

  • CI and air-gapped environments need to authenticate and generate without reaching the platform.
  • Connected users pay ~3 s per command for /v1/auth/validate even when a signed license token already proves everything the validate call returns.

Stacked on #2124 (commercial license election + generator token validation); base branch build/bump-generator-license-election, retarget main after it merges.

What Changed

Offline authentication

  • Resolution order (internal/license.Resolve), first configured source wins: SPEAKEASY_LICENSE_TOKEN (raw JWT) → SPEAKEASY_LICENSE_FILE (path to a JWT) → offline_license_token in ~/.speakeasy/config.yaml. An unreadable, invalid, or other-workspace source is skipped with a warning naming the source (never the token) and authentication falls back to the platform.
  • The offline license is also ignored (with a warning) when an API key is configured but no workspace has been persisted yet — without a known workspace there is no proof the license belongs to the key's workspace, and using it would bind another workspace's identity to the key (registry uploads and events would target the license's workspace with the key's credentials).
  • New OfflineCapable field on model.ExecutableCommand, set on speakeasy run and speakeasy generate sdk. Those commands authenticate via auth.CommandContext, which prefers a usable offline license. All other authenticated commands authenticate online as before.
  • Offline context: built from the token's claims; /v1/auth/validate is skipped. With a configured API key an SDK client is still installed, so registry uploads, telemetry, and workspace confirmation behave as online. Without an API key there is no SDK client; telemetry and registry are disabled at context construction and workspace confirmation is skipped.
  • Generation access (internal/sdkgen.evaluateGenerationAccess): an offline-license context is allowed without the platform access check — the signed token (expiry + target coverage, validated by the generator) is the access decision. A context with no SDK client is allowed iff it has a token. Otherwise the platform access check runs as before.

Online re-authentication triggers

Each refreshes the stored license as a side effect:

  • auth.EnsureTargets — the offline license does not cover every selected target type (checked in Workflow.Run preparation and per target in sdkgen).
  • auth.EnsurePlatform — a platform API is needed but the offline context has no SDK client: GitHub workflow runs, and Workflow.Run preparation when the selected sources/targets hard-require the registry (frozen lockfile runs, registry inputs or overlays, blocking code-samples registry output). Best-effort registry interactions — source tracking, change reports, source publishing, non-blocking code samples — still skip when the registry is disabled, so typical migrated workflows keep running fully offline.
  • A headless session (non-interactive stdout) with no API key fails fast with the offline-license hint instead of opening a browser and hanging.

Persistence

  • A successful online authentication stores the issued token as offline_license_token — only when one was issued, only non-free-tier, only for the authenticated workspace, and not inside GitHub Actions: a token persisted mid-job (e.g. by generate sdk version before run) would silently flip the same job's later commands to offline auth and bypass the per-generation platform access check, and the container is ephemeral anyway.
  • A tokenless re-authentication into the same workspace preserves the stored token; switching workspaces clears it and prints a notice; speakeasy auth logout clears it.
  • Authentication while speakeasy-self is the default workspace remains a no-op (as on main), preserving the stored customer id and token.
  • speakeasy auth login always forces a fresh browser authentication, ignoring the configured API key — the explicit path to bypass the offline license and refresh the persisted one.

Known limitations (offline, no API key)

  • The speakeasy repro success line is suppressed (events are never uploaded, so the ID would resolve to nothing).
  • Target testing enablement and the test-report URL rely on token claims and uploaded events; the studio (--watch) requires platform authentication.
  • An offline license used with a revoked API key surfaces platform errors at the first platform call (e.g. registry upload) rather than at startup; speakeasy auth login refreshes credentials.

Testing

  • go build ./...; unit suite (excluding integration) green.
  • Unit coverage: auth fallbacks (internal/auth/auth_test.go), token persistence rules incl. the speakeasy-self no-op (internal/config/config_test.go), license resolution (internal/license/license_test.go), registry-dependent workflow preparation incl. best-effort/non-blocking negatives (internal/run/prepare_test.go).
  • Prod smoke in a sandboxed $HOME: API key only / garbage token / stale token / other-workspace token / broken SPEAKEASY_LICENSE_TOKEN → online path, commercial output, fresh token stored. Valid token without API key → fully offline commercial generation. Valid token with API key → validate skipped (~3 s per command), token-based access.
  • Adversarially audited (three independent reviews: sdk-generation-action compatibility, offline-path platform calls, auth-flow regressions vs main); the GitHub-Actions persistence skip, the unknown-workspace guard, the narrowed registry triggers, and the headless fail-fast came out of that audit.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Running ultrareview automatically — This PR introduces offline license authentication, altering core auth, license resolution, and config persistence across 13 files; a subtle bug could break CLI auth or permit unauthorized generation.. I'll post findings when complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ultrareview completed in 11m 37s

All reported issues were addressed across 13 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread internal/run/run.go Outdated
Comment thread internal/license/license.go Outdated
Comment thread internal/run/run.go Outdated
@ThomasRooney
ThomasRooney force-pushed the feat/offline-license-auth branch from 571ca9d to 07d00de Compare September 1, 2026 10:01

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 15 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread internal/auth/auth.go
@ThomasRooney
ThomasRooney force-pushed the feat/offline-license-auth branch from 07d00de to 5fa3b8d Compare September 1, 2026 12:02

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 13 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread internal/run/run.go
Comment thread internal/auth/auth_test.go
Comment thread internal/run/run.go Outdated
@ThomasRooney
ThomasRooney force-pushed the feat/offline-license-auth branch 3 times, most recently from a63f30c to 30cd2ed Compare September 1, 2026 14:33
@ThomasRooney
ThomasRooney force-pushed the build/bump-generator-license-election branch from 93d1529 to 2cd9b57 Compare September 1, 2026 14:35
@ThomasRooney
ThomasRooney force-pushed the feat/offline-license-auth branch 6 times, most recently from e299702 to 7f21479 Compare September 1, 2026 16:59
ThomasRooney added a commit that referenced this pull request Sep 2, 2026
… commercial license (#2124)

## Why

- The platform issues target-scoped license tokens
(speakeasy-registry#4708, live) and the generator validates them offline
(openapi-generation#55); the CLI has to attach them to every generation.
- The CLI generates only under the customer's commercial license. The
AGPL election is a source-build fallback in the upstream generator and
is not used by the CLI.
- On `main`, the paid-tier "not in the approved SDK target list" warn
path has produced AGPL-stamped output since the generation-context
change.

## What Changed

Dependencies: `openapi-generation/v2` v2.933.1 → v2.934.0 (offline
license validation with target-scoped tokens), `generation-context`
v1.0.0 → v1.1.0 (`WithDirect` no longer implies AGPL; the election is
explicit). Uses `speakeasy-core` v0.24.0 (`access.CheckGenerationAccess`
exposing `AccessDetails.license_jwt`) and `speakeasy-client-sdk-go/v3`
v3.28.1, already on `main`.

- Every generation elects commercial
(`internal/sdkgen.withGenerationContext`) and attaches the authenticated
context's license token; the generator validates signature, expiry, and
target coverage offline. `/v1/auth/validate` issues the token for every
tier (speakeasy-api/speakeasy-registry#4709: commercial `["*"]` at 30d,
free `["*"]` at 24h), so the context token is authoritative and the
access check's token is not consulted.
- No token: the election is still commercial and the generator rejects
the run as an unproven commercial election.
- Blocked access (`Level == blocked`) aborts before generation,
unchanged.
- Result vs `main`: the paid-tier warn path produces commercial output
again. Free workspaces get commercial output for their one language; a
second language is blocked by the access check.
- `lint --dry-run` elects commercial with the context's token. A dry run
that fails before producing any warnings (unauthenticated, or a rejected
election) skips that target, and the summary names the skipped targets
instead of reporting "no warnings found".
- Standalone `generate codeSamples` and `generate usage` — dead on
`main` (`ErrMissingGenerationAccess`, no generation state ever
established) — now elect through the same helper: they work when
authenticated and fail with a clear "unauthenticated" error otherwise.
- `internal/run` performs no election of its own (the workflow-level
AGPL/direct election is removed); election happens in `internal/sdkgen`.
Validation elects authenticated commercial for authenticated callers
(keeping SDK access, e.g. link shortening) and direct OSS mode
otherwise; it attaches no license token, since it produces no licensed
output and an invalid token must not fail read-only diagnostics.

**Rollout prerequisite:** the deployed platform must issue `license_jwt`
from `/v1/auth/validate` for every allowed tier before this ships — a
platform that authenticates without issuing a token now produces a hard
generator rejection (unproven commercial election) where `main` fell
back to AGPL. Hosted prod satisfies this as of
speakeasy-api/speakeasy-registry#4709 (deployed); older
self-hosted/staging registries are the population at risk.

Follow-up: #2127 adds offline license authentication on top of this.

## Testing

- `go build ./...`; unit suite (excluding `integration`) green;
golangci-lint clean on touched packages.
- Unit coverage: election
(`internal/sdkgen/generation_context_test.go`).
- Prod smoke in a sandboxed `$HOME` during development: speakeasy-self
`run` → commercial output, no warning. Free workspace `run -t go` →
commercial output; `run -t typescript` → "Upgrade Required … exceeded
the limit of one free generated SDK", blocked.
Base automatically changed from build/bump-generator-license-election to main September 2, 2026 13:25
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.
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.
@ThomasRooney
ThomasRooney force-pushed the feat/offline-license-auth branch from 7f21479 to e1b1f32 Compare September 2, 2026 13:26

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant