Skip to content
Draft
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions config/clients/dotnet/template/Client_OAuth2Client.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public class OAuth2Client {
}

_httpClient = httpClient;
_apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer;
_apiTokenIssuer = BuildApiTokenEndpoint(credentialsConfig.Config.ApiTokenIssuer);
_authRequest = new Dictionary<string, string> {
{ "client_id", credentialsConfig.Config.ClientId },
{ "client_secret", credentialsConfig.Config.ClientSecret },
Expand All @@ -118,8 +118,8 @@ public class OAuth2Client {
private async Task ExchangeTokenAsync(CancellationToken cancellationToken = default) {
var requestBuilder = new RequestBuilder<IDictionary<string, string>> {
Method = HttpMethod.Post,
BasePath = $"https://{_apiTokenIssuer}",
PathTemplate = "/oauth/token",
BasePath = _apiTokenIssuer,
PathTemplate = "",
Body = _authRequest,
ContentType = "application/x-www-form-urlencode"
};
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,24 @@ public class Credentials: IAuthCredentialsConfig {
/// </summary>
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();
}

/// <summary>
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 8 additions & 9 deletions config/clients/dotnet/template/api_test.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ namespace {{testPackageName}}.Api {
}
}
};
void ActionMalformedApiTokenIssuer() => config.EnsureValid();
var exception = Assert.Throws<FgaValidationError>(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);
}

/// <summary>
Expand Down Expand Up @@ -295,7 +294,7 @@ namespace {{testPackageName}}.Api {
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(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<CancellationToken>()
Expand Down Expand Up @@ -350,7 +349,7 @@ namespace {{testPackageName}}.Api {
"SendAsync",
Times.Exactly(1),
ItExpr.Is<HttpRequestMessage>(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<CancellationToken>()
Expand Down Expand Up @@ -394,7 +393,7 @@ namespace {{testPackageName}}.Api {
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(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<CancellationToken>()
Expand Down Expand Up @@ -449,7 +448,7 @@ namespace {{testPackageName}}.Api {
"SendAsync",
Times.Exactly(2),
ItExpr.Is<HttpRequestMessage>(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<CancellationToken>()
Expand Down Expand Up @@ -493,7 +492,7 @@ namespace {{testPackageName}}.Api {
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(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<CancellationToken>()
Expand Down Expand Up @@ -545,7 +544,7 @@ namespace {{testPackageName}}.Api {
"SendAsync",
Times.Exactly(3),
ItExpr.Is<HttpRequestMessage>(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<CancellationToken>()
Expand Down
37 changes: 31 additions & 6 deletions config/clients/js/template/credentials/credentials.ts.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}
}
8 changes: 6 additions & 2 deletions config/clients/js/template/tests/helpers/nocks.ts.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions config/clients/js/template/tests/index.test.ts.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -73,7 +73,7 @@ describe("{{appTitleCaseName}} SDK", function () {
}
} as Configuration["credentials"]
})
).toThrowError();
).not.toThrowError();
});

it("should not require credentials in configuration when not needed", () => {
Expand Down
Loading