Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 33 additions & 2 deletions generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,7 @@ export class EndpointSnippetGenerator {
case "header":
return values.type === "header" ? this.getConstructorHeaderAuthArg({ auth, values }) : TypeInst.nop();
case "oauth":
this.addWarning("The Go SDK doesn't support OAuth client credentials yet");
return TypeInst.nop();
return values.type === "oauth" ? this.getConstructorOAuthArg({ auth, values }) : TypeInst.nop();
case "inferred":
this.addWarning("The Go SDK Generator does not support Inferred auth scheme yet");
return TypeInst.nop();
Expand Down Expand Up @@ -483,6 +482,38 @@ export class EndpointSnippetGenerator {
});
}

private getConstructorOAuthArg({
auth,
values
}: {
auth: FernIr.dynamic.OAuth;
values: FernIr.dynamic.OAuthValues;
}): go.AstNode {
return go.codeblock((writer) => {
writer.writeNode(
go.invokeFunc({
func: go.typeReference({
name: "WithOAuthTokenProvider",
importPath: this.context.getOptionImportPath()
}),
arguments_: [
go.invokeFunc({
func: go.typeReference({
name: "NewOAuthTokenProvider",
importPath: `${this.context.rootImportPath}/core`
}),
arguments_: [
go.TypeInstantiation.string(values.clientId),
go.TypeInstantiation.string(values.clientSecret),
go.codeblock("nil") // refreshFunc will be set by the SDK internally
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This doesn't seem great for users

]
})
]
})
);
});
}

private getConstructorHeaderArgs({
headers,
values
Expand Down
24 changes: 24 additions & 0 deletions generators/go/internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,9 @@ func (g *Generator) generate(ir *fernir.IntermediateRepresentation, mode Mode) (
files = append(files, newMultipartFile(g.coordinator))
files = append(files, newMultipartTestFile(g.coordinator))
}
if needsOAuthHelpers(ir) {
files = append(files, newOAuthFile(g.coordinator))
}
clientTestFile, err := newClientTestFile(g.config.FullImportPath, rootPackageName, g.coordinator, g.config.ClientName, g.config.ClientConstructorName)
if err != nil {
return nil, err
Expand Down Expand Up @@ -1266,6 +1269,14 @@ func newOptionalTestFile(coordinator *coordinator.Client) *File {
)
}

func newOAuthFile(coordinator *coordinator.Client) *File {
return NewFile(
coordinator,
"core/oauth.go",
[]byte(oauthFile),
)
}

func newQueryFile(coordinator *coordinator.Client) *File {
return NewFile(
coordinator,
Expand Down Expand Up @@ -1900,6 +1911,19 @@ func needsFileUploadHelpers(ir *fernir.IntermediateRepresentation) bool {
return false
}

// needsOAuthHelpers returns true if OAuth is used in the IR.
func needsOAuthHelpers(ir *fernir.IntermediateRepresentation) bool {
if ir.Auth == nil {
return false
}
for _, authScheme := range ir.Auth.Schemes {
if authScheme.Oauth != nil {
return true
}
}
return false
}

func isReservedFilename(filename string) bool {
_, ok := reservedFilenames[filename]
return ok
Expand Down
90 changes: 90 additions & 0 deletions generators/go/internal/generator/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ var (
//go:embed sdk/core/optional_test.go
optionalTestFile string

//go:embed sdk/core/oauth.go
oauthFile string

//go:embed sdk/utils/pointer.go
pointerFile string

Expand Down Expand Up @@ -311,6 +314,7 @@ func (f *fileWriter) WriteRequestOptionsDefinition(
f.P("MaxAttempts uint")

// Generate the exported RequestOptions type that all clients can act upon.
var hasOAuth bool
for _, authScheme := range auth.Schemes {
if authScheme.Bearer != nil {
f.P(authScheme.Bearer.Token.PascalCase.UnsafeName, " string")
Expand All @@ -329,6 +333,10 @@ func (f *fileWriter) WriteRequestOptionsDefinition(
typeReferenceToGoType(authScheme.Header.ValueType, f.types, f.scope, f.baseImportPath, importPath, false),
)
}
if authScheme.Oauth != nil {
hasOAuth = true
f.P("OAuthTokenProvider *OAuthTokenProvider")
}
}
for _, header := range headers {
if !shouldGenerateHeader(header, f.types) {
Expand Down Expand Up @@ -449,6 +457,24 @@ func (f *fileWriter) WriteRequestOptionsDefinition(
f.P("}")
f.P()

// Generate the ToHeaderWithContext method if OAuth is used.
if hasOAuth {
f.P("// ToHeaderWithContext maps the configured request options into a http.Header used")
f.P("// for the request(s). It handles OAuth token refresh if an OAuthTokenProvider is set.")
f.P("func (r *RequestOptions) ToHeaderWithContext(ctx context.Context) (http.Header, error) {")
f.P("header := r.ToHeader()")
f.P("if r.OAuthTokenProvider != nil {")
f.P("token, err := r.OAuthTokenProvider.GetToken(ctx)")
f.P("if err != nil {")
f.P("return nil, err")
f.P("}")
f.P(`header.Set("Authorization", "Bearer " + token)`)
f.P("}")
f.P("return header, nil")
f.P("}")
f.P()
}

if err := f.writePlatformHeaders(sdkConfig, moduleConfig, sdkVersion); err != nil {
return err
}
Expand Down Expand Up @@ -557,6 +583,11 @@ func (f *fileWriter) writeRequestOptionStructs(
return err
}
}
if authScheme.Oauth != nil {
if err := f.writeOptionStruct("OAuthTokenProvider", "*OAuthTokenProvider", true, asIdempotentRequestOption); err != nil {
return err
}
}
}
}

Expand Down Expand Up @@ -835,6 +866,39 @@ func (f *fileWriter) WriteRequestOptions(
f.P("}")
f.P()
}
if authScheme.Oauth != nil {
if i == 0 {
option = ast.NewCallExpr(
ast.NewImportedReference(
"WithOAuthTokenProvider",
importPath,
),
[]ast.Expr{
ast.NewBasicLit(`oauthTokenProvider`),
},
)
if authScheme.Oauth.Configuration != nil && authScheme.Oauth.Configuration.ClientCredentials != nil {
cc := authScheme.Oauth.Configuration.ClientCredentials
if cc.ClientIdEnvVar != nil {
environmentVars = append(environmentVars, string(*cc.ClientIdEnvVar))
}
if cc.ClientSecretEnvVar != nil {
environmentVars = append(environmentVars, string(*cc.ClientSecretEnvVar))
}
}
}
f.P("// WithOAuthTokenProvider sets the OAuth token provider for automatic token refresh.")
if includeCustomAuthDocs {
f.P("//")
f.WriteDocs(auth.Docs)
}
f.P("func WithOAuthTokenProvider(tokenProvider *core.OAuthTokenProvider) *core.OAuthTokenProviderOption {")
f.P("return &core.OAuthTokenProviderOption{")
f.P("OAuthTokenProvider: tokenProvider,")
f.P("}")
f.P("}")
f.P()
}
}

for _, header := range headers {
Expand Down Expand Up @@ -1061,6 +1125,32 @@ func (f *fileWriter) WriteClient(
f.P(receiver, ".header.Clone(),")
f.P("options.ToHeader(),")
f.P(")")
// Handle OAuth token refresh if OAuth is used.
if irAuth != nil {
for _, authScheme := range irAuth.Schemes {
if authScheme.Oauth != nil {
tokenPrefix := "Bearer"
tokenHeader := "Authorization"
if authScheme.Oauth.Configuration != nil && authScheme.Oauth.Configuration.ClientCredentials != nil {
cc := authScheme.Oauth.Configuration.ClientCredentials
if cc.TokenPrefix != nil {
tokenPrefix = *cc.TokenPrefix
}
if cc.TokenHeader != nil {
tokenHeader = *cc.TokenHeader
}
}
f.P("if ", receiver, ".options.OAuthTokenProvider != nil {")
f.P("oauthToken, err := ", receiver, ".options.OAuthTokenProvider.GetToken(ctx)")
f.P("if err != nil {")
f.P("return ", endpoint.ErrorReturnValues)
f.P("}")
f.P(fmt.Sprintf(`%s.Set(%q, %q + " " + oauthToken)`, headersParameter, tokenHeader, tokenPrefix))
f.P("}")
break
}
}
}
if len(endpoint.Headers) > 0 {
// Add endpoint-specific headers from the request, if any.
for _, header := range endpoint.Headers {
Expand Down
78 changes: 78 additions & 0 deletions generators/go/internal/generator/sdk/core/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package core

import (
"context"
"sync"
"time"
)

const (
// expiryBufferMinutes is the buffer time before token expiry to trigger a refresh.
expiryBufferMinutes = 2
)

// OAuthTokenProvider manages OAuth tokens with automatic refresh.
type OAuthTokenProvider struct {
clientID string
clientSecret string
accessToken string
expiresAt time.Time
refreshFunc func(ctx context.Context, clientID, clientSecret string) (*OAuthTokenResponse, error)
mu sync.Mutex
}

// OAuthTokenResponse represents the response from an OAuth token endpoint.
type OAuthTokenResponse struct {
AccessToken string
ExpiresIn *int // seconds until expiry (optional)
}

// NewOAuthTokenProvider creates a new OAuthTokenProvider.
func NewOAuthTokenProvider(
clientID string,
clientSecret string,
refreshFunc func(ctx context.Context, clientID, clientSecret string) (*OAuthTokenResponse, error),
) *OAuthTokenProvider {
return &OAuthTokenProvider{
clientID: clientID,
clientSecret: clientSecret,
refreshFunc: refreshFunc,
}
}

// GetToken returns a valid access token, refreshing if necessary.
func (o *OAuthTokenProvider) GetToken(ctx context.Context) (string, error) {
// Fast path: check if token is still valid without acquiring lock
if o.accessToken != "" && (o.expiresAt.IsZero() || time.Now().Before(o.expiresAt)) {
return o.accessToken, nil
}

o.mu.Lock()
defer o.mu.Unlock()

// Double-check after acquiring lock
if o.accessToken != "" && (o.expiresAt.IsZero() || time.Now().Before(o.expiresAt)) {
return o.accessToken, nil
}

return o.refresh(ctx)
}

// refresh fetches a new token from the OAuth endpoint.
func (o *OAuthTokenProvider) refresh(ctx context.Context) (string, error) {
response, err := o.refreshFunc(ctx, o.clientID, o.clientSecret)
if err != nil {
return "", err
}

o.accessToken = response.AccessToken
if response.ExpiresIn != nil {
// Set expiry time with buffer
o.expiresAt = time.Now().Add(time.Duration(*response.ExpiresIn)*time.Second - expiryBufferMinutes*time.Minute)
} else {
// No expiry info, token is valid indefinitely
o.expiresAt = time.Time{}
}

return o.accessToken, nil
}
10 changes: 10 additions & 0 deletions generators/go/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json

- version: 1.19.0
changelogEntry:
- summary: |
Add support for OAuth client credentials authentication with automatic token refresh.
The SDK now includes an OAuthTokenProvider that handles token management with a 2-minute
buffer before expiry to trigger refresh. Thread-safe token access is ensured using mutex locks.
type: feat
createdAt: "2025-12-08"
irVersion: 60

- version: 1.18.4
changelogEntry:
- summary: |
Expand Down
10 changes: 9 additions & 1 deletion seed/go-sdk/oauth-client-credentials-default/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading