diff --git a/Client/ClientSecretAuthentication.cs b/Client/ClientSecretAuthentication.cs new file mode 100644 index 0000000..f81ce13 --- /dev/null +++ b/Client/ClientSecretAuthentication.cs @@ -0,0 +1,79 @@ +using System.Net.Http; +using IdokladSdk; +using IdokladSdk.Authentication; +using IdokladSdk.Authentication.Models; +using IdokladSdk.Exceptions; +using Newtonsoft.Json; + +namespace Idoklad.Client; + +/// +/// iDoklad client_credentials authentication that sends only client_id and +/// client_secret (no application_id). +/// +/// The official SDK's mandates an applicationId and +/// always posts application_id, but iDoklad's standard client_credentials grant does not +/// require it. This implementation is used when the caller does not supply an ApplicationId. +/// +public sealed class ClientSecretAuthentication : IAuthentication +{ + private const string Scope = "idoklad_api"; + + private readonly string _clientId; + private readonly string _clientSecret; + + public ClientSecretAuthentication(string clientId, string clientSecret) + { + _clientId = clientId; + _clientSecret = clientSecret; + } + + public DokladConfiguration Configuration { get; set; } = default!; + + public string RefreshToken => null!; + + public bool UseRefreshToken { get => false; set { } } + + public async Task GetTokenAsync(HttpClient httpClient, CancellationToken cancellationToken = default) + { + var postData = new List> + { + new("grant_type", "client_credentials"), + new("client_id", _clientId), + new("client_secret", _clientSecret), + new("scope", Scope), + }; + + // The SDK targets the v2 token endpoint (server/v2/connect/token), which mandates + // application_id. Without an applicationId we use the classic endpoint + // (server/connect/token), which authenticates with client_id + client_secret only. + var tokenUrl = Configuration.IdentityServerTokenUrl.ToString() + .Replace("/server/v2/connect/token", "/server/connect/token"); + + using var request = new HttpRequestMessage(HttpMethod.Post, tokenUrl) + { + Content = new FormUrlEncodedContent(postData), + }; + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new IdokladAuthenticationException(content); + } + + var tokenizer = JsonConvert.DeserializeObject(content); + if (tokenizer is null || string.IsNullOrEmpty(tokenizer.AccessToken)) + { + var error = JsonConvert.DeserializeObject(content); + throw new IdokladAuthenticationException(error); + } + + // GrantType defaults to ClientCredentials (enum value 0), which is correct here. + return tokenizer; + } + + public Task RefreshAccessTokenAsync(HttpClient httpClient, CancellationToken cancellationToken = default) + => GetTokenAsync(httpClient, cancellationToken); +} diff --git a/Client/ClientSecretDokladApiBuilder.cs b/Client/ClientSecretDokladApiBuilder.cs new file mode 100644 index 0000000..07db762 --- /dev/null +++ b/Client/ClientSecretDokladApiBuilder.cs @@ -0,0 +1,21 @@ +using IdokladSdk; +using IdokladSdk.Authentication; +using IdokladSdk.Builders; + +namespace Idoklad.Client; + +/// +/// variant that uses a caller-supplied +/// (here ) instead of the SDK's applicationId-mandatory +/// client credentials. The rest of the builder pipeline (HttpClient, options, urls, Build) is reused. +/// +public sealed class ClientSecretDokladApiBuilder : DokladApiBuilder +{ + private readonly IAuthentication _authentication; + + public ClientSecretDokladApiBuilder(string appName, string appVersion, IAuthentication authentication) + : base(appName, appVersion) + => _authentication = authentication; + + protected override IAuthentication GetAuthentication() => _authentication; +} diff --git a/Client/DokladApiFactory.cs b/Client/DokladApiFactory.cs index 8927f01..fcecd8d 100644 --- a/Client/DokladApiFactory.cs +++ b/Client/DokladApiFactory.cs @@ -27,8 +27,17 @@ public sealed class DokladApiFactory { var httpClient = _httpClientFactory.CreateClient(HttpClientName); - var builder = new DokladApiBuilder(_settings.AppName, _settings.AppVersion) - .AddClientCredentialsAuthentication(credentials.ClientId, credentials.ClientSecret, credentials.ApplicationId) + // With an ApplicationId, use the SDK's client_credentials (sends application_id). + // Without it, authenticate with client_id + client_secret only (iDoklad's default). + DokladApiBuilder builder = string.IsNullOrWhiteSpace(credentials.ApplicationId) + ? new ClientSecretDokladApiBuilder( + _settings.AppName, + _settings.AppVersion, + new ClientSecretAuthentication(credentials.ClientId, credentials.ClientSecret)) + : new DokladApiBuilder(_settings.AppName, _settings.AppVersion) + .AddClientCredentialsAuthentication(credentials.ClientId, credentials.ClientSecret, credentials.ApplicationId); + + builder = builder .AddHttpClient(httpClient) .AddApiContextOptions(options => options.Language = credentials.Language); diff --git a/Credentials/IdokladCredentials.cs b/Credentials/IdokladCredentials.cs index eb4ad07..be32f15 100644 --- a/Credentials/IdokladCredentials.cs +++ b/Credentials/IdokladCredentials.cs @@ -10,6 +10,13 @@ public sealed record IdokladCredentials { public required string ClientId { get; init; } public required string ClientSecret { get; init; } - public required string ApplicationId { get; init; } + + /// + /// Optional iDoklad ApplicationId (GUID from the developer portal). Only needed for partner + /// applications whose client_credentials grant requires application_id. When empty, the + /// service authenticates with client_id + client_secret only. + /// + public string? ApplicationId { get; init; } + public required Language Language { get; init; } } diff --git a/Credentials/RequestCredentialsProvider.cs b/Credentials/RequestCredentialsProvider.cs index f56936c..5b2d022 100644 --- a/Credentials/RequestCredentialsProvider.cs +++ b/Credentials/RequestCredentialsProvider.cs @@ -28,12 +28,12 @@ public sealed class RequestCredentialsProvider var clientId = HeaderOrDefault(headers, CredentialConstants.ClientIdHeader, _settings.ClientId); var clientSecret = HeaderOrDefault(headers, CredentialConstants.ClientSecretHeader, _settings.ClientSecret); + // ApplicationId is optional: iDoklad's standard client_credentials grant does not require it. var applicationId = HeaderOrDefault(headers, CredentialConstants.ApplicationIdHeader, _settings.ApplicationId); var missing = new List(); if (string.IsNullOrWhiteSpace(clientId)) missing.Add(CredentialConstants.ClientIdHeader); if (string.IsNullOrWhiteSpace(clientSecret)) missing.Add(CredentialConstants.ClientSecretHeader); - if (string.IsNullOrWhiteSpace(applicationId)) missing.Add(CredentialConstants.ApplicationIdHeader); if (missing.Count > 0) { throw new MissingCredentialsException(missing); @@ -43,7 +43,7 @@ public sealed class RequestCredentialsProvider { ClientId = clientId!, ClientSecret = clientSecret!, - ApplicationId = applicationId!, + ApplicationId = string.IsNullOrWhiteSpace(applicationId) ? null : applicationId, Language = ResolveLanguage(headers), }; } diff --git a/Infrastructure/CredentialHeadersOperationFilter.cs b/Infrastructure/CredentialHeadersOperationFilter.cs index 5b361c6..5290684 100644 --- a/Infrastructure/CredentialHeadersOperationFilter.cs +++ b/Infrastructure/CredentialHeadersOperationFilter.cs @@ -30,7 +30,7 @@ public sealed class CredentialHeadersOperationFilter : IOperationFilter "iDoklad OAuth2 ClientSecret (SENSITIVE). Must be sent in this header over TLS only — never in the URL or body. Overrides the IDOKLAD_CLIENT_SECRET environment default. Required if no environment default is configured."); AddHeader(operation, CredentialConstants.ApplicationIdHeader, - "iDoklad ApplicationId from the developer portal. Overrides the IDOKLAD_APPLICATION_ID environment default. Required by the client credentials flow if no environment default is configured."); + "OPTIONAL. iDoklad ApplicationId (GUID from the developer portal). Only needed for partner applications whose client_credentials grant requires application_id. Leave empty for a standard app — authentication then uses client_id + client_secret only. Overrides IDOKLAD_APPLICATION_ID."); AddHeader(operation, CredentialConstants.LanguageHeader, "Optional response language override for the iDoklad API: Cz, Sk or En.", example: "Cz"); diff --git a/Program.cs b/Program.cs index 55cb27b..9b2c969 100644 --- a/Program.cs +++ b/Program.cs @@ -59,13 +59,14 @@ builder.Services.AddSwaggerGen(options => "using the OAuth2 client credentials flow.\n\n" + "**Credentials.** Every agenda endpoint needs an iDoklad ClientId, ClientSecret and ApplicationId. " + "Sensitive values are required in request headers and are never accepted in the query string or body:\n\n" + - "- `X-ClientId` — iDoklad OAuth2 ClientId\n" + - "- `X-ClientSecret` — iDoklad OAuth2 ClientSecret (sensitive; TLS only)\n" + - "- `X-ApplicationId` — iDoklad ApplicationId from the developer portal\n" + + "- `X-ClientId` — iDoklad OAuth2 ClientId (required)\n" + + "- `X-ClientSecret` — iDoklad OAuth2 ClientSecret (required; sensitive; TLS only)\n" + + "- `X-ApplicationId` — OPTIONAL ApplicationId (GUID) for partner apps only; leave empty for a standard app\n" + "- `X-Idoklad-Language` — optional response language (Cz, Sk, En)\n\n" + "If a header is omitted, the matching environment default " + "(`IDOKLAD_CLIENT_ID`, `IDOKLAD_CLIENT_SECRET`, `IDOKLAD_APPLICATION_ID`) is used. " + - "If neither a header nor a default is available, the request is rejected with 401.", + "Only ClientId and ClientSecret are required; if either is missing the request is rejected with 401. " + + "When no ApplicationId is provided, authentication uses client_id + client_secret only.", }); options.OperationFilter();