Skip to content

fix(cli): preserve per-endpoint security when auth-schemes defined without auth key#16037

Open
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1779367549-fix-auth-schemes-per-endpoint-security
Open

fix(cli): preserve per-endpoint security when auth-schemes defined without auth key#16037
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1779367549-fix-auth-schemes-per-endpoint-security

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Description

When generators.yml defines auth-schemes (e.g., OAuth client-credentials + bearer) but no explicit auth key, the CLI was merging all auth schemes into a single global security group and stripping per-endpoint security from the OpenAPI spec. This caused all endpoints to appear with the same auth options regardless of their actual security requirements.

Related: fern-api/fern-platform#11327 (rendering fix for the docs playground)

Changes Made

OpenAPIConverter.ts

  • overrideOpenApiAuthWithGeneratorsAuth(): Changed guard to require both auth AND auth-schemes before overriding OpenAPI security. When only auth-schemes is present (no auth key), per-endpoint security from the OpenAPI spec is now preserved.
  • convertSecuritySchemes(): When auth-schemes is present without auth, synthesizes auth: { any: [...schemeNames] } so that convertAuthOverrides correctly registers scheme types (OAuth client-credentials vs bearer) from the fern definitions.

OSSWorkspace.ts

  • Populates authOverrides when auth-schemes is present (not just auth), so the converter receives the scheme type definitions.
  • Updated getAuthFromOverrideFiles() to also check for auth-schemes in override files.
  • Updated toFernWorkspace() to skip reading from override files when auth-schemes is already in the generators config.

Testing

Verified by generating IR from a Dropbox-style config with:

auth-schemes:
  dropboxAppAuth:
    scheme: oauth
    type: client-credentials
    ...
  dropboxOAuth2:
    scheme: bearer

Before fix: All endpoints had [{"dropboxAppAuth": [], "dropboxOAuth2": [], "dropboxTeamAuth": []}] (one merged group)

After fix:

  • List Folder (dual-auth): 2 groups — ["dropboxAppAuth"], ["dropboxOAuth2"]

  • Get Photo (single-auth): 1 group — ["dropboxOAuth2"]

  • Top-level: dropboxAppAuth = oauth (clientCredentials), dropboxOAuth2 = bearer, dropboxTeamAuth = bearer

  • Manual testing completed

  • Unit tests added/updated

Link to Devin session: https://app.devin.ai/sessions/ca3d4110d2874227ae4ca857e41dcc01

…thout auth key

Co-Authored-By: will.kendall@buildwithfern.com <wpk235@gmail.com>
@devin-ai-integration devin-ai-integration Bot requested a review from amckinney as a code owner May 21, 2026 12:46
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@willkendall01
Copy link
Copy Markdown
Contributor

@claude review once

Comment on lines 574 to 582
let effectiveSettings = settings;
if (this.generatorsConfiguration?.api?.auth == null && settings?.auth == null) {
if (
this.generatorsConfiguration?.api?.auth == null &&
this.generatorsConfiguration?.api?.["auth-schemes"] == null &&
settings?.auth == null
) {
const specs = await this.getOpenAPISpecsCached({ context });
const authFromOverrides = await getAuthFromOverrideFiles(specs);
if (authFromOverrides != null) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When generators.yml declares auth-schemes (but no auth) and the spec's override file declares auth:, the new guard at OSSWorkspace.ts:574-582 skips the override-file fallback, leaving effectiveSettings.auth undefined and causing FernDefinitionConverter.buildAuthOverrides to return undefined — the override file's auth is silently dropped. Fix by only skipping the fallback when override files would re-declare auth-schemes, e.g. read the override file first and honor its auth even when auth-schemes is in generators.yml.

Extended reasoning...

The bug: The new condition in toFernWorkspace at packages/cli/workspace/lazy-fern-workspace/src/OSSWorkspace.ts:574-578 now skips the override-file fallback when generators.yml has auth-schemes, conflating two distinct concepts: auth-schemes declares the available schemes, while auth declares which scheme(s) are used. For users who split the config — auth-schemes in generators.yml, auth in the spec override file — the override-file auth is now silently ignored.

Code path that triggers it:

  1. toFernWorkspace checks the guard at OSSWorkspace.ts:574-578. Because generatorsConfiguration.api["auth-schemes"] is set, the new condition is false and getAuthFromOverrideFiles is skipped.
  2. effectiveSettings.auth remains undefined.
  3. getDefinition calls FernDefinitionConverter.convert, which calls buildAuthOverrides(settings.auth, settings.authSchemes) with both undefined.
  4. In FernDefinitionConverter.ts:84-96: perGeneratorAuth is undefined, topLevelAuth = generatorsConfiguration.api.auth is also undefined, so effectiveAuth = undefined ?? undefined = undefined and the function returns undefined at line 94-96.
  5. Downstream convert() receives no authOverrides, and the override file's auth declaration is silently dropped.

Why other code doesn't save us: The companion change in getIntermediateRepresentation (lines 320-323) also skips getAuthFromOverrideFiles when authOverrides is non-null, and authOverrides is non-null in this scenario (because auth-schemes is in generators.yml). And buildAuthOverrides strictly requires effectiveAuth != null to return anything, so the presence of auth-schemes alone is not enough to produce overrides in the toFernWorkspace path.

Impact: Silent data loss for a legitimate split configuration. Generated SDKs will be missing the auth specified in the override file. The previous behavior (before this PR) correctly picked up auth from the override file in this scenario.

Step-by-step proof:
generators.yml:

api:
  auth-schemes:
    myBearer:
      scheme: bearer

overrides.yaml:

auth: myBearer
  • Pre-PR: guard auth == null && settings?.auth == null is true. getAuthFromOverrideFiles returns { auth: 'myBearer' }. effectiveSettings.auth = 'myBearer'. buildAuthOverrides('myBearer', undefined): effectiveAuth = 'myBearer', returns { auth: 'myBearer', 'auth-schemes': { myBearer: { scheme: bearer } } }. Auth is applied.
  • Post-PR: guard now also requires auth-schemes == null, which is false (auth-schemes is in generators.yml). Fallback skipped. effectiveSettings.auth = undefined. buildAuthOverrides(undefined, undefined): effectiveAuth = undefined, returns undefined at FernDefinitionConverter.ts:94-96. The override file's auth: myBearer is silently dropped.

Suggested fix: Don't conflate auth and auth-schemes. Either (a) read the override file first and only skip the fields that generators.yml already provides (so auth-schemes from generators.yml takes precedence over override-file auth-schemes, but override-file auth is still respected), or (b) keep the guard scoped to the auth key only and let buildAuthOverrides merge auth-schemes from both sources.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fcb720a. The guard now only requires auth == null (not auth-schemes == null) to trigger the override-file fallback. When auth-schemes is in generators.yml but the override file has auth, we now honor the override file's auth while skipping its auth-schemes (since generators.yml already defines those). This handles the split-config case correctly.

Comment on lines 317 to +322
const documents = await this.loader.loadDocuments({ context, specs });

let authOverrides: RawSchemas.WithAuthSchema | undefined =
this.generatorsConfiguration?.api?.auth != null ? { ...this.generatorsConfiguration?.api } : undefined;
this.generatorsConfiguration?.api?.auth != null ||
this.generatorsConfiguration?.api?.["auth-schemes"] != null
? { ...this.generatorsConfiguration?.api }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 AsyncAPIConverter has no parallel handling for the new auth-schemes-without-auth case enabled by this PR's OSSWorkspace change, so any workspace combining AsyncAPI specs with auth-schemes-only config in generators.yml (or override files via getAuthFromOverrideFiles) will have its AsyncAPI security schemes silently wiped from the IR. Mirror the synthesis logic added to OpenAPIConverter.convertSecuritySchemes() in AsyncAPIConverter.convertSecuritySchemes() (synthesize auth: { any: schemeNames } when only auth-schemes is present), or do the synthesis once in OSSWorkspace before handing off to converters.

Extended reasoning...

What the bug is

The PR's change at OSSWorkspace.ts:317-323 now populates authOverrides whenever generators.yml has auth-schemes, even with no auth key. The same authOverrides value is forwarded to both converters: OpenAPIConverterContext3_1 and AsyncAPIConverterContext (OSSWorkspace.ts:373 and OSSWorkspace.ts:400 respectively). The PR also updates OpenAPIConverter.convertSecuritySchemes() to handle this new shape by synthesizing auth: { any: schemeNames }, but AsyncAPIConverter.convertSecuritySchemes() (packages/cli/api-importers/asyncapi-to-ir/src/AsyncAPIConverter.ts:166-189) was not given the analogous treatment.

The code path that triggers it

// AsyncAPIConverter.ts:166-189
private convertSecuritySchemes(): void {
    if (this.context.authOverrides) {           // <-- now truthy in the new case
        const overrideAuth = convertApiAuth({
            rawApiFileSchema: this.context.authOverrides,
            casingsGenerator: this.context.casingsGenerator
        });
        this.addAuthToIR({ ...overrideAuth });
        return;                                 // <-- early return skips the fallback
    }
    const asyncApiSchemes = this.convertAsyncApiSecuritySchemes();
    ...
}

convertApiAuth (packages/commons/ir-utils/src/auth/convertApiAuth.ts:19-25) short-circuits when rawApiFileSchema.auth == null, returning { requirement: All, schemes: [] }. So addAuthToIR writes empty schemes and the function returns before convertAsyncApiSecuritySchemes() can parse the AsyncAPI spec's own components.securitySchemes.

Why existing code does not prevent it

Pre-PR, the guard in OSSWorkspace was this.generatorsConfiguration?.api?.auth != null, so this exact scenario (auth-schemes only) left authOverrides undefined and AsyncAPIConverter fell through to convertAsyncApiSecuritySchemes(). The new guard auth != null || "auth-schemes" != null flips that, but only OpenAPIConverter was updated to compensate.

Impact

Any workspace combining at least one AsyncAPI spec with a generators.yml that defines auth-schemes (and no top-level auth) will have all of its AsyncAPI security wiped from the IR with no error. The same regression applies to the override-file path: getAuthFromOverrideFiles (OSSWorkspace.ts:854) was extended in this PR to return an authOverrides object containing only auth-schemes, so the same wiping triggers for AsyncAPI specs whose overrides file defines only auth-schemes. In mixed OpenAPI+AsyncAPI projects mergeIntermediateRepresentation (mergeIntermediateRepresentation.ts:23-30) takes the longer schemes array, so OpenAPI auth can mask the regression — but AsyncAPI-only projects (or AsyncAPI-namespaced sub-APIs) will ship SDKs with no auth.

Step-by-step proof

Given:

# generators.yml
api:
  specs:
    - asyncapi: ./events.yml
  auth-schemes:
    apiKey:
      header: X-Api-Key

and an events.yml AsyncAPI document with:

components:
  securitySchemes:
    apiKey:
      type: httpApiKey
      in: header
      name: X-Api-Key
  1. OSSWorkspace.getIntermediateRepresentation enters the new branch at line 319-322 because api["auth-schemes"] != null. authOverrides = { "auth-schemes": { apiKey: {...} } } (no auth key).
  2. The AsyncAPI document hits the case "asyncapi": branch at line 391-411; authOverrides is passed into AsyncAPIConverterContext at OSSWorkspace.ts:400.
  3. AsyncAPIConverter.convert() calls convertSecuritySchemes(). Line 167 if (this.context.authOverrides) is truthy.
  4. convertApiAuth({ rawApiFileSchema: { "auth-schemes": {...} } }) hits the guard at convertApiAuth.ts:19-25 because rawApiFileSchema.auth == null and returns { docs: undefined, requirement: All, schemes: [] }.
  5. addAuthToIR({ requirement: All, schemes: [] }) is called, then return at line 178 — convertAsyncApiSecuritySchemes() (which would have produced an AuthScheme for apiKey from the spec's components.securitySchemes) is never invoked.
  6. The IR ships with empty auth.schemes and the generated SDK has no auth wiring for the AsyncAPI events.

Pre-PR, step 1 would not have populated authOverrides (guard required auth != null), so step 3's if would be falsy and step 5 would correctly read the scheme from the AsyncAPI spec.

Fix

Mirror the OpenAPIConverter synthesis in AsyncAPIConverter.convertSecuritySchemes() so that when authOverrides.auth == null but authOverrides["auth-schemes"] is non-empty, it synthesizes auth: { any: Object.keys(authOverrides["auth-schemes"]) } and passes that into convertApiAuth. Alternatively, do the synthesis once in OSSWorkspace before handing authOverrides to either converter so the two stay in sync without duplication.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fcb720a. Added the same synthesis logic (auth: { any: schemeNames } when only auth-schemes is present) to AsyncAPIConverter.convertSecuritySchemes(), mirroring what OpenAPIConverter already does.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-05-21T05:23:53Z).

Fixture main PR Delta
docs 218.9s (n=5) 219.2s (35 versions) +0.3s (+0.1%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-05-21T05:23:53Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-05-21 14:56 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-05-21T05:23:53Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 72s (n=5) 109s (n=5) 61s -11s (-15.3%)
go-sdk square 133s (n=5) 294s (n=5) 125s -8s (-6.0%)
java-sdk square 208s (n=5) 274s (n=5) 185s -23s (-11.1%)
php-sdk square 54s (n=5) 80s (n=5) 51s -3s (-5.6%)
python-sdk square 130s (n=5) 229s (n=5) 130s +0s (+0.0%)
ruby-sdk-v2 square 85s (n=5) 118s (n=5) 83s -2s (-2.4%)
rust-sdk square 167s (n=5) 163s (n=5) 164s -3s (-1.8%)
swift-sdk square 59s (n=5) 746s (n=5) 49s -10s (-16.9%)
ts-sdk square 231s (n=5) 240s (n=5) 222s -9s (-3.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-05-21T05:23:53Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-05-21 14:58 UTC

devin-ai-integration Bot and others added 3 commits May 21, 2026 13:20
… AsyncAPI parallel handling

Co-Authored-By: will.kendall@buildwithfern.com <wpk235@gmail.com>
Co-Authored-By: will.kendall@buildwithfern.com <wpk235@gmail.com>
…logging

Co-Authored-By: will.kendall@buildwithfern.com <wpk235@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant