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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
20 changes: 14 additions & 6 deletions generators/csharp/sdk/src/root-client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,16 +466,24 @@ export class RootClientGenerator extends FileGenerator<CSharpFile, SdkGeneratorC
const passwordAccess = unified
? `clientOptions.${this.toPascalCase(passwordName)}`
: passwordName;
const eitherOmitted =
basicScheme.usernameOmit === true || basicScheme.passwordOmit === true;
const condition = eitherOmitted
? `${usernameAccess} != null || ${passwordAccess} != null`
: `${usernameAccess} != null && ${passwordAccess} != null`;
if (isAuthOptional || basicSchemes.length > 1) {
const controlFlowKeyword = i === 0 ? "if" : "else if";
innerWriter.controlFlow(
controlFlowKeyword,
this.csharp.codeblock(`${usernameAccess} != null && ${passwordAccess} != null`)
innerWriter.controlFlow(controlFlowKeyword, this.csharp.codeblock(condition));
}
if (eitherOmitted) {
innerWriter.writeTextStatement(
`clientOptionsWithAuth.Headers["Authorization"] = $"Basic {Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes($"{${usernameAccess} ?? ""}:{${passwordAccess} ?? ""}"))}"`
);
} else {
innerWriter.writeTextStatement(
`clientOptionsWithAuth.Headers["Authorization"] = $"Basic {Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes($"{${usernameAccess}}:{${passwordAccess}}"))}"`
);
}
innerWriter.writeTextStatement(
`clientOptionsWithAuth.Headers["Authorization"] = $"Basic {Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes($"{${usernameAccess}}:{${passwordAccess}}"))}"`
);
if (isAuthOptional || basicSchemes.length > 1) {
innerWriter.endControlFlow();
}
Expand Down
12 changes: 12 additions & 0 deletions generators/csharp/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 2.55.3
changelogEntry:
- summary: |
Support optional username and password in basic auth. The SDK now accepts
username-only, password-only, or both credentials. Missing fields are treated
as empty strings (e.g., username-only encodes `username:`, password-only
encodes `:password`). When neither is provided, the Authorization header is
omitted entirely.
type: feat
createdAt: "2026-03-31"
irVersion: 65

- version: 2.55.2
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,39 @@ public GeneratedJavaFile generateFile() {

private MethodSpec buildGetAuthHeaders(
ClassName endpointMetadataClassName, FieldSpec usernameSupplierField, FieldSpec passwordSupplierField) {
return MethodSpec.methodBuilder("getAuthHeaders")
boolean eitherOmitted = basicAuthScheme.getUsernameOmit().orElse(false)
|| basicAuthScheme.getPasswordOmit().orElse(false);

MethodSpec.Builder builder = MethodSpec.methodBuilder("getAuthHeaders")
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Override.class)
.addParameter(endpointMetadataClassName, "endpointMetadata")
.returns(ParameterizedTypeName.get(Map.class, String.class, String.class))
.addStatement("String username = $N.get()", usernameSupplierField)
.addStatement("String password = $N.get()", passwordSupplierField)
.beginControlFlow("if (username == null || password == null)")
.addStatement("throw new $T(AUTH_CONFIG_ERROR_MESSAGE)", RuntimeException.class)
.endControlFlow()
.addStatement("String credentials = username + \":\" + password")
.addStatement(
.returns(ParameterizedTypeName.get(Map.class, String.class, String.class));

if (eitherOmitted) {
builder.addStatement(
"String username = $N != null ? $N.get() : null",
usernameSupplierField,
usernameSupplierField)
.addStatement(
"String password = $N != null ? $N.get() : null",
passwordSupplierField,
passwordSupplierField)
.beginControlFlow("if (username == null && password == null)")
.addStatement("throw new $T(AUTH_CONFIG_ERROR_MESSAGE)", RuntimeException.class)
.endControlFlow()
.addStatement(
"String credentials = (username != null ? username : \"\") + \":\" + (password != null ? password : \"\")");
} else {
builder.addStatement("String username = $N.get()", usernameSupplierField)
.addStatement("String password = $N.get()", passwordSupplierField)
.beginControlFlow("if (username == null || password == null)")
.addStatement("throw new $T(AUTH_CONFIG_ERROR_MESSAGE)", RuntimeException.class)
.endControlFlow()
.addStatement("String credentials = username + \":\" + password");
}

return builder.addStatement(
"String encoded = $T.getEncoder().encodeToString(credentials.getBytes($T.UTF_8))",
Base64.class,
StandardCharsets.class)
Expand All @@ -136,6 +157,10 @@ private MethodSpec buildGetAuthHeaders(
}

private MethodSpec buildCanCreateMethod(String usernameEnvVar, String passwordEnvVar) {
boolean eitherOmitted = basicAuthScheme.getUsernameOmit().orElse(false)
|| basicAuthScheme.getPasswordOmit().orElse(false);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
String combiner = eitherOmitted ? ") || (" : ") && (";

ParameterizedTypeName stringSupplierType =
ParameterizedTypeName.get(ClassName.get(Supplier.class), ClassName.get(String.class));

Expand All @@ -151,7 +176,7 @@ private MethodSpec buildCanCreateMethod(String usernameEnvVar, String passwordEn
if (usernameEnvVar != null) {
condition.append(" || System.getenv(\"").append(usernameEnvVar).append("\") != null");
}
condition.append(") && (passwordSupplier != null");
condition.append(combiner).append("passwordSupplier != null");
if (passwordEnvVar != null) {
condition.append(" || System.getenv(\"").append(passwordEnvVar).append("\") != null");
}
Expand Down
11 changes: 11 additions & 0 deletions generators/java/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 4.0.10
changelogEntry:
- summary: |
Support optional username and password in basic auth. The SDK now accepts
username-only, password-only, or both credentials. Missing fields are treated
as empty strings (e.g., username-only encodes `username:`, password-only
encodes `:password`). When neither is provided, a RuntimeException is thrown.
type: feat
createdAt: "2026-03-31"
irVersion: 65

- version: 4.0.9
changelogEntry:
- summary: |
Expand Down
21 changes: 15 additions & 6 deletions generators/php/sdk/src/root-client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,16 +356,25 @@ export class RootClientGenerator extends FileGenerator<PhpFile, SdkCustomConfigS
}
const usernameName = this.context.getParameterName(basicAuthScheme.username);
const passwordName = this.context.getParameterName(basicAuthScheme.password);
// usernameOmit/passwordOmit may exist in newer IR versions
const scheme = basicAuthScheme as unknown as Record<string, unknown>;
const eitherOmitted = scheme.usernameOmit === true || scheme.passwordOmit === true;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if (isAuthOptional || basicAuthSchemes.length > 1) {
const controlFlowKeyword = i === 0 ? "if" : "else if";
writer.controlFlow(
controlFlowKeyword,
php.codeblock(`$${usernameName} !== null && $${passwordName} !== null`)
const condition = eitherOmitted
? `$${usernameName} !== null || $${passwordName} !== null`
: `$${usernameName} !== null && $${passwordName} !== null`;
writer.controlFlow(controlFlowKeyword, php.codeblock(condition));
}
if (eitherOmitted) {
writer.writeLine(
`$defaultHeaders['Authorization'] = "Basic " . base64_encode(($${usernameName} ?? "") . ":" . ($${passwordName} ?? ""));`
);
} else {
writer.writeLine(
`$defaultHeaders['Authorization'] = "Basic " . base64_encode($${usernameName} . ":" . $${passwordName});`
);
}
writer.writeLine(
`$defaultHeaders['Authorization'] = "Basic " . base64_encode($${usernameName} . ":" . $${passwordName});`
);
if (isAuthOptional || basicAuthSchemes.length > 1) {
writer.endControlFlow();
}
Expand Down
12 changes: 12 additions & 0 deletions generators/php/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 2.2.7
changelogEntry:
- summary: |
Support optional username and password in basic auth. The SDK now accepts
username-only, password-only, or both credentials. Missing fields are treated
as empty strings (e.g., username-only encodes `username:`, password-only
encodes `:password`). When neither is provided, the Authorization header is
omitted entirely.
type: feat
createdAt: "2026-03-31"
irVersion: 62

- version: 2.2.6
changelogEntry:
- summary: |
Expand Down
13 changes: 12 additions & 1 deletion generators/python/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
# For unreleased changes, use unreleased.yml
- version: 5.2.1
changelogEntry:
- summary: |
Support optional username and password in basic auth when configured in IR.
When usernameOmit or passwordOmit is set, the SDK accepts username-only,
password-only, or both credentials. Missing fields are treated as empty strings
(e.g., username-only encodes `username:`, password-only encodes `:password`).
When neither is provided, the Authorization header is omitted entirely.
type: feat
createdAt: "2026-03-31"
irVersion: 65

- version: 5.2.0
changelogEntry:
- summary: |
Expand All @@ -11,7 +23,6 @@
type: feat
createdAt: "2026-03-31"
irVersion: 65

- version: 5.1.3
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,36 +523,63 @@ def _write_get_headers_body(writer: AST.NodeWriter) -> None:
writer.write_newline_if_last_line_not()
basic_auth_scheme = self._get_basic_auth_scheme()
if basic_auth_scheme is not None:
either_omitted = (
getattr(basic_auth_scheme, "username_omit", None) is True
or getattr(basic_auth_scheme, "password_omit", None) is True
)
if not self._context.ir.sdk_config.is_auth_mandatory:
username_var = names.get_username_constructor_parameter_name(basic_auth_scheme)
password_var = names.get_password_constructor_parameter_name(basic_auth_scheme)
writer.write_line(f"{username_var} = self.{names.get_username_getter_name(basic_auth_scheme)}()")
writer.write_line(f"{password_var} = self.{names.get_password_getter_name(basic_auth_scheme)}()")
writer.write_line(f"if {username_var} is not None and {password_var} is not None:")
condition_op = "or" if either_omitted else "and"
writer.write_line(f"if {username_var} is not None {condition_op} {password_var} is not None:")
with writer.indent():
writer.write(f'headers["{ClientWrapperGenerator.AUTHORIZATION_HEADER}"] = ')
if either_omitted:
writer.write_node(
AST.ClassInstantiation(
class_=httpx.HttpX.BASIC_AUTH,
args=[
AST.Expression(f'{username_var} or ""'),
AST.Expression(f'{password_var} or ""'),
],
)
)
else:
writer.write_node(
AST.ClassInstantiation(
class_=httpx.HttpX.BASIC_AUTH,
args=[
AST.Expression(f"{username_var}"),
AST.Expression(f"{password_var}"),
],
)
)
writer.write("._auth_header")
writer.write_newline_if_last_line_not()
else:
writer.write(f'headers["{ClientWrapperGenerator.AUTHORIZATION_HEADER}"] = ')
if either_omitted:
writer.write_node(
AST.ClassInstantiation(
class_=httpx.HttpX.BASIC_AUTH,
args=[
AST.Expression(f"{username_var}"),
AST.Expression(f"{password_var}"),
AST.Expression(f'self.{names.get_username_getter_name(basic_auth_scheme)}() or ""'),
AST.Expression(f'self.{names.get_password_getter_name(basic_auth_scheme)}() or ""'),
],
)
)
Comment on lines +563 to 572
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing validation when auth is mandatory and either_omitted is true. The code directly encodes credentials without checking if both are None/empty.

If both username and password are None, this will encode ":" (just a colon) and set it as the Authorization header, violating the requirement that "when neither is provided, the Authorization header is omitted entirely."

Fix:

if either_omitted:
    username_expr = f"self.{names.get_username_getter_name(basic_auth_scheme)}()"
    password_expr = f"self.{names.get_password_getter_name(basic_auth_scheme)}()"
    writer.write_line(f"_username = {username_expr}")
    writer.write_line(f"_password = {password_expr}")
    writer.write_line("if _username is None and _password is None:")
    with writer.indent():
        writer.write_line('raise ValueError("At least one of username or password must be provided")')
    writer.write(f'headers["{ClientWrapperGenerator.AUTHORIZATION_HEADER}"] = ')
    writer.write_node(
        AST.ClassInstantiation(
            class_=httpx.HttpX.BASIC_AUTH,
            args=[
                AST.Expression('_username or ""'),
                AST.Expression('_password or ""'),
            ],
        )
    )

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

writer.write("._auth_header")
writer.write_newline_if_last_line_not()
else:
writer.write(f'headers["{ClientWrapperGenerator.AUTHORIZATION_HEADER}"] = ')
writer.write_node(
AST.ClassInstantiation(
class_=httpx.HttpX.BASIC_AUTH,
args=[
AST.Expression(f"self.{names.get_username_getter_name(basic_auth_scheme)}()"),
AST.Expression(f"self.{names.get_password_getter_name(basic_auth_scheme)}()"),
],
else:
writer.write_node(
AST.ClassInstantiation(
class_=httpx.HttpX.BASIC_AUTH,
args=[
AST.Expression(f"self.{names.get_username_getter_name(basic_auth_scheme)}()"),
AST.Expression(f"self.{names.get_password_getter_name(basic_auth_scheme)}()"),
],
)
)
)
writer.write("._auth_header")
writer.write_newline_if_last_line_not()
for param in constructor_parameters:
Expand Down
14 changes: 10 additions & 4 deletions generators/ruby-v2/sdk/src/root-client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,21 +122,27 @@ export class RootClientGenerator extends FileGenerator<RubyFile, SdkCustomConfig
}
const usernameName = basicAuthScheme.username.snakeCase.safeName;
const passwordName = basicAuthScheme.password.snakeCase.safeName;
// usernameOmit/passwordOmit may exist in newer IR versions
const scheme = basicAuthScheme as unknown as Record<string, unknown>;
const eitherOmitted = scheme.usernameOmit === true || scheme.passwordOmit === true;
Comment on lines +126 to +127
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Ruby generator uses as unknown as Record<string, unknown> in violation of CLAUDE.md TypeScript rules

CLAUDE.md explicitly prohibits as unknown as X type assertions: "Never use as any or as unknown as X. These are escape hatches that bypass the type system entirely. If the types don't line up, fix the types." The Ruby generator uses as unknown as Record<string, unknown> to access usernameOmit/passwordOmit properties. The C# generator at generators/csharp/sdk/src/root-client/RootClientGenerator.ts:470 accesses the same properties directly on the typed object without any cast, and the Java generator uses typed getUsernameOmit() accessors (generators/java/sdk/src/main/java/com/fern/java/client/generators/auth/BasicAuthProviderGenerator.java:117), indicating these fields already exist on the IR types and a proper typed access should be used.

Prompt for agents
In generators/ruby-v2/sdk/src/root-client/RootClientGenerator.ts at lines 126-127, replace the `as unknown as Record<string, unknown>` cast with direct typed property access on basicAuthScheme, matching how the C# generator accesses basicScheme.usernameOmit directly at generators/csharp/sdk/src/root-client/RootClientGenerator.ts:470. Change:

const scheme = basicAuthScheme as unknown as Record<string, unknown>;
const eitherOmitted = scheme.usernameOmit === true || scheme.passwordOmit === true;

To:

const eitherOmitted = basicAuthScheme.usernameOmit === true || basicAuthScheme.passwordOmit === true;

If the IR type for the Ruby generator's BasicAuthScheme does not include these fields, update the IR type or import to include them rather than bypassing the type system.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The as unknown as Record<string, unknown> cast is intentional here. The Ruby generator uses IR SDK v61 (@fern-fern/ir-sdk@^61.7.0) which does not include usernameOmit/passwordOmit on BasicAuthScheme — those fields were added in v63+. The C# generator can access them directly because it uses IR SDK v65.

Bumping the Ruby generator's IR SDK dependency to v65 would be a larger change with potential side effects and is beyond the scope of this PR. The cast safely handles the version mismatch — if the fields don't exist at runtime (older IR), the === true check returns false, preserving the default "both required" behavior.

const conditionOp = eitherOmitted ? "||" : "&&";
const usernameExpr = eitherOmitted ? `${usernameName} || ""` : usernameName;
const passwordExpr = eitherOmitted ? `${passwordName} || ""` : passwordName;
if (isAuthOptional || basicAuthSchemes.length > 1) {
if (i === 0) {
writer.writeLine(`if !${usernameName}.nil? && !${passwordName}.nil?`);
writer.writeLine(`if !${usernameName}.nil? ${conditionOp} !${passwordName}.nil?`);
} else {
writer.writeLine(`elsif !${usernameName}.nil? && !${passwordName}.nil?`);
writer.writeLine(`elsif !${usernameName}.nil? ${conditionOp} !${passwordName}.nil?`);
}
writer.writeLine(
` headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameName}}:#{${passwordName}}")}"`
` headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameExpr}}:#{${passwordExpr}}")}"`
);
if (i === basicAuthSchemes.length - 1) {
writer.writeLine(`end`);
}
} else {
writer.writeLine(
`headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameName}}:#{${passwordName}}")}"`
`headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameExpr}}:#{${passwordExpr}}")}"`
);
}
}
Expand Down
12 changes: 12 additions & 0 deletions generators/ruby-v2/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json

- version: 1.1.12
changelogEntry:
- summary: |
Support optional username and password in basic auth. The SDK now accepts
username-only, password-only, or both credentials. Missing fields are treated
as empty strings (e.g., username-only encodes `username:`, password-only
encodes `:password`). When neither is provided, the Authorization header is
omitted entirely.
type: feat
createdAt: "2026-03-31"
irVersion: 61

- version: 1.1.11
changelogEntry:
- summary: |
Expand Down
Loading
Loading