Skip to content

Commit dc8ab91

Browse files
author
fortinbra
committed
feat(api): add /api/v1/config endpoint for client configuration
Enables the Blazor WASM client to fetch OIDC configuration from the API at startup instead of using static wwwroot/appsettings.json. This allows Docker deployments to configure auth via environment variables without rebuilding. Changes: - Add ClientConfigDto, AuthenticationConfigDto, OidcConfigDto in Contracts - Add ClientConfigOptions with ToDto() mapping in API - Add ConfigController with [AllowAnonymous] GET endpoint - Wire up ClientConfigOptions from IConfiguration in Program.cs - Update Blazor Client to fetch config from API at startup - Add ClientId property to AuthentikOptions - Clear static auth settings from Client wwwroot/appsettings.json - Add unit tests for ClientConfigOptions.ToDto() mapping (6 tests) - Add integration tests for /api/v1/config endpoint (4 tests) Closes #56
1 parent c73f513 commit dc8ab91

File tree

10 files changed

+1014
-10
lines changed

10 files changed

+1014
-10
lines changed

docs/056-api-config-endpoint.md

Lines changed: 505 additions & 0 deletions
Large diffs are not rendered by default.

src/BudgetExperiment.Api/AuthentikOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,16 @@ public sealed class AuthentikOptions
2222

2323
/// <summary>
2424
/// Gets or sets the audience (typically the client ID or API identifier).
25+
/// Used for API JWT token validation.
2526
/// </summary>
2627
public string Audience { get; set; } = string.Empty;
2728

29+
/// <summary>
30+
/// Gets or sets the client ID for the OIDC client (Blazor WASM).
31+
/// If not specified, defaults to the Audience value.
32+
/// </summary>
33+
public string ClientId { get; set; } = string.Empty;
34+
2835
/// <summary>
2936
/// Gets or sets a value indicating whether HTTPS metadata is required.
3037
/// Should be true in production, can be false for local development.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// <copyright file="ClientConfigOptions.cs" company="BecauseImClever">
2+
// Copyright (c) BecauseImClever. All rights reserved.
3+
// </copyright>
4+
5+
namespace BudgetExperiment.Api;
6+
7+
using BudgetExperiment.Contracts.Dtos;
8+
9+
/// <summary>
10+
/// Strongly-typed options for client configuration.
11+
/// Maps from IConfiguration and exposes settings safe for client consumption.
12+
/// </summary>
13+
public sealed class ClientConfigOptions
14+
{
15+
/// <summary>
16+
/// The configuration section name.
17+
/// </summary>
18+
public const string SectionName = "ClientConfig";
19+
20+
/// <summary>
21+
/// Gets or sets the authentication mode: "none" or "oidc".
22+
/// </summary>
23+
public string AuthMode { get; set; } = "oidc";
24+
25+
/// <summary>
26+
/// Gets or sets the OIDC authority URL.
27+
/// </summary>
28+
public string OidcAuthority { get; set; } = string.Empty;
29+
30+
/// <summary>
31+
/// Gets or sets the OIDC client ID.
32+
/// </summary>
33+
public string OidcClientId { get; set; } = string.Empty;
34+
35+
/// <summary>
36+
/// Gets or sets the OAuth2 response type.
37+
/// </summary>
38+
public string OidcResponseType { get; set; } = "code";
39+
40+
/// <summary>
41+
/// Gets or sets the OIDC scopes.
42+
/// </summary>
43+
public List<string> OidcScopes { get; set; } = ["openid", "profile", "email"];
44+
45+
/// <summary>
46+
/// Gets or sets the post-logout redirect URI.
47+
/// </summary>
48+
public string OidcPostLogoutRedirectUri { get; set; } = "/";
49+
50+
/// <summary>
51+
/// Gets or sets the redirect URI after login.
52+
/// </summary>
53+
public string OidcRedirectUri { get; set; } = "authentication/login-callback";
54+
55+
/// <summary>
56+
/// Converts this options instance to a client-safe DTO.
57+
/// </summary>
58+
/// <returns>A <see cref="ClientConfigDto"/> containing the client configuration.</returns>
59+
public ClientConfigDto ToDto() => new()
60+
{
61+
Authentication = new AuthenticationConfigDto
62+
{
63+
Mode = this.AuthMode,
64+
Oidc = string.Equals(this.AuthMode, "oidc", StringComparison.OrdinalIgnoreCase)
65+
? new OidcConfigDto
66+
{
67+
Authority = this.OidcAuthority,
68+
ClientId = this.OidcClientId,
69+
ResponseType = this.OidcResponseType,
70+
Scopes = this.OidcScopes,
71+
PostLogoutRedirectUri = this.OidcPostLogoutRedirectUri,
72+
RedirectUri = this.OidcRedirectUri,
73+
}
74+
: null,
75+
},
76+
};
77+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// <copyright file="ConfigController.cs" company="BecauseImClever">
2+
// Copyright (c) BecauseImClever. All rights reserved.
3+
// </copyright>
4+
5+
using BudgetExperiment.Contracts.Dtos;
6+
7+
using Microsoft.AspNetCore.Authorization;
8+
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.Extensions.Options;
10+
11+
namespace BudgetExperiment.Api.Controllers;
12+
13+
/// <summary>
14+
/// REST API controller for client configuration.
15+
/// Provides configuration settings for the Blazor WebAssembly client.
16+
/// </summary>
17+
[ApiController]
18+
[AllowAnonymous]
19+
[Route("api/v1/[controller]")]
20+
[Produces("application/json")]
21+
public sealed class ConfigController : ControllerBase
22+
{
23+
private readonly IOptions<ClientConfigOptions> _clientConfigOptions;
24+
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="ConfigController"/> class.
27+
/// </summary>
28+
/// <param name="clientConfigOptions">The client configuration options.</param>
29+
public ConfigController(IOptions<ClientConfigOptions> clientConfigOptions)
30+
{
31+
_clientConfigOptions = clientConfigOptions;
32+
}
33+
34+
/// <summary>
35+
/// Gets the client configuration settings.
36+
/// </summary>
37+
/// <remarks>
38+
/// This endpoint is public (no authentication required) because the client
39+
/// needs configuration before it can authenticate. Only non-secret settings
40+
/// are exposed.
41+
/// </remarks>
42+
/// <returns>The client configuration.</returns>
43+
[HttpGet]
44+
[ProducesResponseType<ClientConfigDto>(StatusCodes.Status200OK)]
45+
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
46+
public ActionResult<ClientConfigDto> GetConfig()
47+
{
48+
var options = _clientConfigOptions.Value;
49+
return Ok(options.ToDto());
50+
}
51+
}

src/BudgetExperiment.Api/Program.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ public static async Task Main(string[] args)
4141
builder.Services.AddScoped<IUserContext, UserContext>();
4242
ConfigureAuthentication(builder.Services, builder.Configuration);
4343

44+
// Client configuration (exposed via /api/v1/config endpoint)
45+
ConfigureClientConfig(builder.Services, builder.Configuration);
46+
4447
// Application & Infrastructure
4548
builder.Services.AddApplication();
4649
builder.Services.AddInfrastructure(builder.Configuration);
@@ -251,4 +254,37 @@ private static void ConfigureAuthentication(IServiceCollection services, IConfig
251254

252255
services.AddAuthorization();
253256
}
257+
258+
/// <summary>
259+
/// Configures client configuration options for the /api/v1/config endpoint.
260+
/// Maps Authentik settings to client-safe configuration.
261+
/// </summary>
262+
/// <param name="services">The service collection.</param>
263+
/// <param name="configuration">The configuration root.</param>
264+
private static void ConfigureClientConfig(IServiceCollection services, IConfiguration configuration)
265+
{
266+
services.Configure<ClientConfigOptions>(options =>
267+
{
268+
var authentikSection = configuration.GetSection(AuthentikOptions.SectionName);
269+
270+
// Determine auth mode based on whether Authentik is enabled
271+
var enabled = authentikSection.GetValue<bool?>("Enabled") ?? true;
272+
options.AuthMode = enabled ? "oidc" : "none";
273+
274+
// OIDC settings from Authentik config
275+
options.OidcAuthority = authentikSection.GetValue<string>("Authority") ?? string.Empty;
276+
277+
// ClientId can be explicitly set, or fall back to Audience (common in Authentik setups)
278+
options.OidcClientId = authentikSection.GetValue<string>("ClientId")
279+
?? authentikSection.GetValue<string>("Audience")
280+
?? string.Empty;
281+
282+
// Apply any explicit ClientConfig overrides
283+
var clientSection = configuration.GetSection(ClientConfigOptions.SectionName);
284+
if (clientSection.Exists())
285+
{
286+
clientSection.Bind(options);
287+
}
288+
});
289+
}
254290
}

src/BudgetExperiment.Client/Program.cs

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
using System.Net.Http.Json;
2+
13
using BudgetExperiment.Client;
24
using BudgetExperiment.Client.Services;
5+
using BudgetExperiment.Contracts.Dtos;
36

47
using Microsoft.AspNetCore.Components.Web;
58
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
@@ -9,15 +12,56 @@
912
builder.RootComponents.Add<App>("#app");
1013
builder.RootComponents.Add<HeadOutlet>("head::after");
1114

12-
// Configure OIDC authentication with Authentik
13-
builder.Services.AddOidcAuthentication(options =>
15+
// Fetch configuration from API before configuring services
16+
using var httpClient = new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) };
17+
ClientConfigDto? clientConfig = null;
18+
try
19+
{
20+
clientConfig = await httpClient.GetFromJsonAsync<ClientConfigDto>("api/v1/config");
21+
}
22+
catch (HttpRequestException)
23+
{
24+
// API not available - will fall back to static config if present
25+
}
26+
27+
// Register config as singleton for injection (if available)
28+
if (clientConfig is not null)
29+
{
30+
builder.Services.AddSingleton(clientConfig);
31+
builder.Services.AddSingleton(clientConfig.Authentication);
32+
}
33+
34+
// Configure OIDC authentication
35+
if (clientConfig?.Authentication.Mode == "oidc" && clientConfig.Authentication.Oidc is not null)
36+
{
37+
// Use configuration from API
38+
var oidc = clientConfig.Authentication.Oidc;
39+
builder.Services.AddOidcAuthentication(options =>
40+
{
41+
options.ProviderOptions.Authority = oidc.Authority;
42+
options.ProviderOptions.ClientId = oidc.ClientId;
43+
options.ProviderOptions.ResponseType = oidc.ResponseType;
44+
options.ProviderOptions.PostLogoutRedirectUri = oidc.PostLogoutRedirectUri;
45+
options.ProviderOptions.RedirectUri = oidc.RedirectUri;
46+
47+
foreach (var scope in oidc.Scopes)
48+
{
49+
options.ProviderOptions.DefaultScopes.Add(scope);
50+
}
51+
});
52+
}
53+
else
1454
{
15-
builder.Configuration.Bind("Authentication:Authentik", options.ProviderOptions);
16-
options.ProviderOptions.ResponseType = "code";
17-
options.ProviderOptions.DefaultScopes.Add("openid");
18-
options.ProviderOptions.DefaultScopes.Add("profile");
19-
options.ProviderOptions.DefaultScopes.Add("email");
20-
});
55+
// Fall back to static configuration (legacy support or API unavailable)
56+
builder.Services.AddOidcAuthentication(options =>
57+
{
58+
builder.Configuration.Bind("Authentication:Authentik", options.ProviderOptions);
59+
options.ProviderOptions.ResponseType = "code";
60+
options.ProviderOptions.DefaultScopes.Add("openid");
61+
options.ProviderOptions.DefaultScopes.Add("profile");
62+
options.ProviderOptions.DefaultScopes.Add("email");
63+
});
64+
}
2165

2266
// Register ScopeService as singleton so it persists across the app lifetime
2367
builder.Services.AddSingleton<ScopeService>();

src/BudgetExperiment.Client/wwwroot/appsettings.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
{
2+
"_comment": "Client configuration is now fetched from the API at /api/v1/config. This file is kept as a fallback only.",
23
"Authentication": {
34
"Authentik": {
4-
"Authority": "https://authentik.becauseimclever.com/application/o/budget-experiment/",
5-
"ClientId": "kt22z8MtUCs7d7MBIZQlQfXvV9DjHd98ahp3iT3H",
5+
"Authority": "",
6+
"ClientId": "",
67
"ResponseType": "code",
78
"PostLogoutRedirectUri": "/",
89
"RedirectUri": "authentication/login-callback"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// <copyright file="ClientConfigDto.cs" company="BecauseImClever">
2+
// Copyright (c) BecauseImClever. All rights reserved.
3+
// </copyright>
4+
5+
namespace BudgetExperiment.Contracts.Dtos;
6+
7+
/// <summary>
8+
/// Configuration settings exposed to the Blazor WebAssembly client.
9+
/// This DTO contains ONLY non-secret, client-appropriate settings.
10+
/// </summary>
11+
public sealed class ClientConfigDto
12+
{
13+
/// <summary>
14+
/// Gets or sets the authentication configuration.
15+
/// </summary>
16+
public required AuthenticationConfigDto Authentication { get; init; }
17+
}
18+
19+
/// <summary>
20+
/// Authentication configuration for the client.
21+
/// </summary>
22+
public sealed class AuthenticationConfigDto
23+
{
24+
/// <summary>
25+
/// Gets or sets the authentication mode: "none" or "oidc".
26+
/// When "none", authentication is disabled and all users get default scope.
27+
/// </summary>
28+
public required string Mode { get; init; }
29+
30+
/// <summary>
31+
/// Gets or sets the OIDC provider settings (populated when Mode = "oidc").
32+
/// </summary>
33+
public OidcConfigDto? Oidc { get; init; }
34+
}
35+
36+
/// <summary>
37+
/// OIDC provider configuration.
38+
/// </summary>
39+
public sealed class OidcConfigDto
40+
{
41+
/// <summary>
42+
/// Gets or sets the OIDC authority URL (issuer).
43+
/// </summary>
44+
public required string Authority { get; init; }
45+
46+
/// <summary>
47+
/// Gets or sets the OAuth2 client ID (public identifier, NOT a secret for PKCE flows).
48+
/// </summary>
49+
public required string ClientId { get; init; }
50+
51+
/// <summary>
52+
/// Gets or sets the OAuth2 response type (typically "code" for PKCE).
53+
/// </summary>
54+
public string ResponseType { get; init; } = "code";
55+
56+
/// <summary>
57+
/// Gets or sets the scopes to request during authentication.
58+
/// </summary>
59+
public IReadOnlyList<string> Scopes { get; init; } = ["openid", "profile", "email"];
60+
61+
/// <summary>
62+
/// Gets or sets the redirect URI after logout.
63+
/// </summary>
64+
public string PostLogoutRedirectUri { get; init; } = "/";
65+
66+
/// <summary>
67+
/// Gets or sets the redirect URI after login callback.
68+
/// </summary>
69+
public string RedirectUri { get; init; } = "authentication/login-callback";
70+
}

0 commit comments

Comments
 (0)