From 024c3ea3d6f2226f6cc2ed90327d765ab7ff24df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Aguiar?= Date: Mon, 9 Jun 2025 09:34:46 -0300 Subject: [PATCH] feat(dotnet,js): configurable token endpoint --- .../template/Client_OAuth2Client.mustache | 26 +++++++++++-- .../Configuration_Credentials.mustache | 27 ++++++++++---- .../clients/dotnet/template/api_test.mustache | 17 ++++----- .../credentials/credentials.ts.mustache | 37 ++++++++++++++++--- .../template/tests/helpers/nocks.ts.mustache | 8 +++- .../js/template/tests/index.test.ts.mustache | 4 +- 6 files changed, 90 insertions(+), 29 deletions(-) diff --git a/config/clients/dotnet/template/Client_OAuth2Client.mustache b/config/clients/dotnet/template/Client_OAuth2Client.mustache index 67b44944e..dfe208c02 100644 --- a/config/clients/dotnet/template/Client_OAuth2Client.mustache +++ b/config/clients/dotnet/template/Client_OAuth2Client.mustache @@ -95,7 +95,7 @@ public class OAuth2Client { } _httpClient = httpClient; - _apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer; + _apiTokenIssuer = BuildApiTokenEndpoint(credentialsConfig.Config.ApiTokenIssuer); _authRequest = new Dictionary { { "client_id", credentialsConfig.Config.ClientId }, { "client_secret", credentialsConfig.Config.ClientSecret }, @@ -118,8 +118,8 @@ public class OAuth2Client { private async Task ExchangeTokenAsync(CancellationToken cancellationToken = default) { var requestBuilder = new RequestBuilder> { Method = HttpMethod.Post, - BasePath = $"https://{_apiTokenIssuer}", - PathTemplate = "/oauth/token", + BasePath = _apiTokenIssuer, + PathTemplate = "", Body = _authRequest, ContentType = "application/x-www-form-urlencode" }; @@ -191,5 +191,25 @@ public class OAuth2Client { return _authToken.AccessToken ?? throw new InvalidOperationException(); } + private static string BuildApiTokenEndpoint(string issuer) { + issuer = issuer.Trim(); + + if (!Uri.TryCreate(issuer, UriKind.Absolute, out var uri)) { + if (!Uri.TryCreate($"https://{issuer}", UriKind.Absolute, out uri)) { + throw new FgaValidationError($"Configuration.ApiTokenIssuer does not form a valid URI ({issuer})"); + } + } + + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) { + throw new FgaValidationError("Configuration.ApiTokenIssuer scheme must be http or https"); + } + + if (string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == "/") { + uri = new Uri($"{uri.Scheme}://{uri.Authority}/oauth/token"); + } + + return uri.ToString(); + } + #endregion } diff --git a/config/clients/dotnet/template/Configuration_Credentials.mustache b/config/clients/dotnet/template/Configuration_Credentials.mustache index 906fd9f66..0695810c5 100644 --- a/config/clients/dotnet/template/Configuration_Credentials.mustache +++ b/config/clients/dotnet/template/Configuration_Credentials.mustache @@ -90,10 +90,24 @@ public class Credentials: IAuthCredentialsConfig { /// public ICredentialsConfig? Config { get; set; } - private static bool IsWellFormedUriString(string uri) { - return Uri.TryCreate(uri, UriKind.Absolute, out var uriResult) && - ((uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) && - (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps)); + private static string BuildApiTokenEndpoint(string issuer) { + issuer = issuer.Trim(); + + if (!Uri.TryCreate(issuer, UriKind.Absolute, out var uri)) { + if (!Uri.TryCreate($"https://{issuer}", UriKind.Absolute, out uri)) { + throw new FgaValidationError($"Configuration.ApiTokenIssuer does not form a valid URI ({issuer})"); + } + } + + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) { + throw new FgaValidationError("Configuration.ApiTokenIssuer scheme must be http or https"); + } + + if (string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == "/") { + uri = new Uri($"{uri.Scheme}://{uri.Authority}/oauth/token"); + } + + return uri.ToString(); } /// @@ -125,9 +139,8 @@ public class Credentials: IAuthCredentialsConfig { throw new FgaRequiredParamError("Configuration", nameof(Config.ApiAudience)); } - if (!string.IsNullOrWhiteSpace(Config?.ApiTokenIssuer) && !IsWellFormedUriString($"https://{Config.ApiTokenIssuer}")) { - throw new FgaValidationError( - $"Configuration.ApiTokenIssuer does not form a valid URI (https://{Config.ApiTokenIssuer})"); + if (!string.IsNullOrWhiteSpace(Config?.ApiTokenIssuer)) { + Config.ApiTokenIssuer = BuildApiTokenEndpoint(Config.ApiTokenIssuer); } break; diff --git a/config/clients/dotnet/template/api_test.mustache b/config/clients/dotnet/template/api_test.mustache index e5c34f934..8e1b7fd5e 100644 --- a/config/clients/dotnet/template/api_test.mustache +++ b/config/clients/dotnet/template/api_test.mustache @@ -135,9 +135,8 @@ namespace {{testPackageName}}.Api { } } }; - void ActionMalformedApiTokenIssuer() => config.EnsureValid(); - var exception = Assert.Throws(ActionMalformedApiTokenIssuer); - Assert.Equal("Configuration.ApiTokenIssuer does not form a valid URI (https://https://tokenissuer.{{sampleApiDomain}})", exception.Message); + config.EnsureValid(); + Assert.Equal("https://tokenissuer.{{sampleApiDomain}}/oauth/token", config.Credentials.Config.ApiTokenIssuer); } /// @@ -295,7 +294,7 @@ namespace {{testPackageName}}.Api { .SetupSequence>( "SendAsync", ItExpr.Is(req => - req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && + req.RequestUri == new Uri(config.Credentials.Config.ApiTokenIssuer) && req.Method == HttpMethod.Post && req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() @@ -350,7 +349,7 @@ namespace {{testPackageName}}.Api { "SendAsync", Times.Exactly(1), ItExpr.Is(req => - req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && + req.RequestUri == new Uri(config.Credentials.Config.ApiTokenIssuer) && req.Method == HttpMethod.Post && req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() @@ -394,7 +393,7 @@ namespace {{testPackageName}}.Api { .SetupSequence>( "SendAsync", ItExpr.Is(req => - req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && + req.RequestUri == new Uri(config.Credentials.Config.ApiTokenIssuer) && req.Method == HttpMethod.Post && req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() @@ -449,7 +448,7 @@ namespace {{testPackageName}}.Api { "SendAsync", Times.Exactly(2), ItExpr.Is(req => - req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && + req.RequestUri == new Uri(config.Credentials.Config.ApiTokenIssuer) && req.Method == HttpMethod.Post && req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() @@ -493,7 +492,7 @@ namespace {{testPackageName}}.Api { .SetupSequence>( "SendAsync", ItExpr.Is(req => - req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && + req.RequestUri == new Uri(config.Credentials.Config.ApiTokenIssuer) && req.Method == HttpMethod.Post && req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() @@ -545,7 +544,7 @@ namespace {{testPackageName}}.Api { "SendAsync", Times.Exactly(3), ItExpr.Is(req => - req.RequestUri == new Uri($"https://{config.Credentials.Config.ApiTokenIssuer}/oauth/token") && + req.RequestUri == new Uri(config.Credentials.Config.ApiTokenIssuer) && req.Method == HttpMethod.Post && req.Content.Headers.ContentType.ToString().Equals("application/x-www-form-urlencoded")), ItExpr.IsAny() diff --git a/config/clients/js/template/credentials/credentials.ts.mustache b/config/clients/js/template/credentials/credentials.ts.mustache index d381f6cb1..22d3fd29c 100644 --- a/config/clients/js/template/credentials/credentials.ts.mustache +++ b/config/clients/js/template/credentials/credentials.ts.mustache @@ -82,10 +82,7 @@ export class Credentials { assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience); assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey); - if (!isWellFormedUriString(`https://${authConfig.config?.apiTokenIssuer}`)) { - throw new FgaValidationError( - `Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`); - } + this.parseIssuer(authConfig.config?.apiTokenIssuer); break; } } @@ -133,7 +130,7 @@ export class Credentials { */ private async refreshAccessToken() { const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config; - const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`; + const url = this.parseIssuer(clientCredentials.apiTokenIssuer); const credentialsPayload = await this.buildClientAuthenticationPayload(); try { @@ -211,7 +208,7 @@ export class Credentials { .setSubject(config.clientId) .setJti(randomUUID()) .setIssuer(config.clientId) - .setAudience(`https://${config.apiTokenIssuer}/`) + .setAudience(`${new URL(this.parseIssuer(config.apiTokenIssuer)).origin}/`) .setExpirationTime("2m") .sign(privateKey); return { @@ -234,4 +231,32 @@ export class Credentials { throw new FgaValidationError("Credentials method is set to ClientCredentials, but no clientSecret or clientAssertionSigningKey is provided"); } + + private parseIssuer(issuer: string | undefined): string { + if (!issuer) { + throw new FgaValidationError("apiTokenIssuer must be defined"); + } + + issuer = issuer.trim(); + let url: URL; + try { + url = new URL(issuer); + } catch (err) { + try { + url = new URL(`https://${issuer}`); + } catch { + throw new FgaValidationError(`Configuration.apiTokenIssuer does not form a valid URI (${issuer})`); + } + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new FgaValidationError("Configuration.apiTokenIssuer scheme must be http or https"); + } + + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/oauth/token"; + } + + return url.toString(); + } } diff --git a/config/clients/js/template/tests/helpers/nocks.ts.mustache b/config/clients/js/template/tests/helpers/nocks.ts.mustache index 0cceab527..888721c57 100644 --- a/config/clients/js/template/tests/helpers/nocks.ts.mustache +++ b/config/clients/js/template/tests/helpers/nocks.ts.mustache @@ -36,8 +36,12 @@ export const getNocks = ((nock: typeof Nock) => ({ expiresIn = 300, statusCode = 200, ) => { - return nock(`https://${apiTokenIssuer}`, { reqheaders: { "Content-Type": "application/x-www-form-urlencoded"} }) - .post("/oauth/token") + const url = new URL(apiTokenIssuer.match(/^https?:\/\//) ? apiTokenIssuer : `https://${apiTokenIssuer}`); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/oauth/token"; + } + return nock(`${url.protocol}//${url.host}`, { reqheaders: { "Content-Type": "application/x-www-form-urlencoded"} }) + .post(url.pathname) .reply(statusCode, { access_token: accessToken, expires_in: expiresIn, diff --git a/config/clients/js/template/tests/index.test.ts.mustache b/config/clients/js/template/tests/index.test.ts.mustache index 151327608..551494460 100644 --- a/config/clients/js/template/tests/index.test.ts.mustache +++ b/config/clients/js/template/tests/index.test.ts.mustache @@ -61,7 +61,7 @@ describe("{{appTitleCaseName}} SDK", function () { ).not.toThrowError(); }); - it("should validate apiTokenIssuer in configuration (should not allow scheme as part of the apiTokenIssuer)", () => { + it("should validate apiTokenIssuer in configuration", () => { expect( () => new {{appShortName}}Api({ ...baseConfig, @@ -73,7 +73,7 @@ describe("{{appTitleCaseName}} SDK", function () { } } as Configuration["credentials"] }) - ).toThrowError(); + ).not.toThrowError(); }); it("should not require credentials in configuration when not needed", () => {