Skip to content

Commit b48bc69

Browse files
committed
test token request from within app
1 parent f63ba8b commit b48bc69

File tree

9 files changed

+412
-39
lines changed

9 files changed

+412
-39
lines changed

src/Runtime/operator/config/local-minimal/localtestapp.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ spec:
4343
ingressRoute:
4444
name: localtestapp-ingress
4545
entryPoints:
46-
- web
46+
- traefik
4747
routes:
48-
- match: PathPrefix(`/localtestapp`)
48+
- match: PathPrefix(`/ttd/localtestapp`)
4949
kind: Rule
5050
services:
51-
- name: localtestapp
51+
- name: ttd-localtestapp-deployment
5252
port: 80
5353
resources:
5454
requests:

src/Runtime/operator/internal/maskinporten/http_api_client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,15 @@ func (c *HttpApiClient) GetAllClients(ctx context.Context) ([]ClientResponse, er
174174

175175
result := make([]ClientResponse, 0, 16)
176176
for _, cl := range dtos {
177+
if cl.ClientId == "" {
178+
return nil, fmt.Errorf("found client with empty ID")
179+
}
180+
if c.context.ServiceOwnerId == "digdir" && cl.ClientId == c.getConfig().ClientId {
181+
// If this operator is running as digdir, the supplier client is also defined there
182+
// so we need to skip it (we should never change or process the supplier client from here)
183+
// TODO: unless we want to rotate JWKS automatically from the operator? o_O
184+
continue
185+
}
177186
if cl.ClientName == nil {
178187
return nil, fmt.Errorf("client with ID %s has no name", cl.ClientId)
179188
}

src/Runtime/operator/test/app/App/App.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@
88

99
<ItemGroup>
1010
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
11+
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
12+
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
1113
</ItemGroup>
1214
</Project>
Lines changed: 216 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,216 @@
1-
using App;
2-
3-
var builder = WebApplication.CreateBuilder(args);
4-
5-
builder.Services.AddHostedService<Worker>();
6-
7-
var app = builder.Build();
8-
9-
app.MapGet("/health", () => TypedResults.Ok());
10-
11-
app.Run();
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Net.Http.Headers;
3+
using System.Security.Claims;
4+
using System.Security.Cryptography;
5+
using System.Text;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
8+
using App;
9+
using Microsoft.IdentityModel.Tokens;
10+
11+
var builder = WebApplication.CreateBuilder(args);
12+
13+
builder.Services.AddHostedService<Worker>();
14+
builder.Services.AddHttpClient();
15+
16+
var app = builder.Build();
17+
18+
app.MapGet("/health", () => TypedResults.Ok());
19+
20+
app.MapGet("/ttd/localtestapp/token", async (HttpContext context, IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory) =>
21+
{
22+
var logger = loggerFactory.CreateLogger("App");
23+
logger.LogInformation("Received token request with scope: {scope}", context.Request.Query["scope"].ToString());
24+
25+
var scope = context.Request.Query["scope"].ToString();
26+
if (string.IsNullOrEmpty(scope))
27+
{
28+
return Results.Json(new { success = false, error = "Missing 'scope' query parameter" });
29+
}
30+
31+
try
32+
{
33+
// Read the maskinporten-settings.json from mounted secret
34+
const string secretPath = "/mnt/app-secrets/maskinporten-settings.json";
35+
if (!File.Exists(secretPath))
36+
{
37+
return Results.Json(new { success = false, error = $"Secret file not found at {secretPath}" });
38+
}
39+
40+
var settingsJson = await File.ReadAllTextAsync(secretPath);
41+
var settings = JsonSerializer.Deserialize<MaskinportenSettings>(settingsJson);
42+
if (settings == null)
43+
{
44+
return Results.Json(new { success = false, error = "Failed to deserialize settings" });
45+
}
46+
47+
if (string.IsNullOrEmpty(settings.ClientId))
48+
{
49+
return Results.Json(new { success = false, error = "ClientId is empty in settings" });
50+
}
51+
52+
if (settings.Jwk == null)
53+
{
54+
return Results.Json(new { success = false, error = "JWK is null in settings" });
55+
}
56+
57+
// Create RSA key from JWK
58+
var rsa = RSA.Create();
59+
var rsaParams = new RSAParameters
60+
{
61+
Modulus = Base64UrlDecode(settings.Jwk.N),
62+
Exponent = Base64UrlDecode(settings.Jwk.E),
63+
D = Base64UrlDecode(settings.Jwk.D),
64+
P = Base64UrlDecode(settings.Jwk.P),
65+
Q = Base64UrlDecode(settings.Jwk.Q),
66+
DP = Base64UrlDecode(settings.Jwk.Dp),
67+
DQ = Base64UrlDecode(settings.Jwk.Dq),
68+
InverseQ = Base64UrlDecode(settings.Jwk.Qi)
69+
};
70+
rsa.ImportParameters(rsaParams);
71+
72+
var securityKey = new RsaSecurityKey(rsa) { KeyId = settings.Jwk.Kid };
73+
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);
74+
75+
// Create JWT assertion
76+
var now = DateTime.UtcNow;
77+
var claims = new[]
78+
{
79+
new Claim("scope", scope),
80+
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
81+
};
82+
83+
var tokenDescriptor = new SecurityTokenDescriptor
84+
{
85+
Subject = new ClaimsIdentity(claims),
86+
Issuer = settings.ClientId,
87+
Audience = settings.Authority,
88+
Expires = now.AddSeconds(60),
89+
IssuedAt = now,
90+
NotBefore = now,
91+
SigningCredentials = credentials
92+
};
93+
94+
var tokenHandler = new JwtSecurityTokenHandler();
95+
var jwtToken = tokenHandler.CreateToken(tokenDescriptor);
96+
var assertion = tokenHandler.WriteToken(jwtToken);
97+
98+
// Call the Maskinporten token endpoint
99+
var httpClient = httpClientFactory.CreateClient();
100+
var tokenUrl = $"http://fakes.runtime-operator.svc.cluster.local:8050/token?grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={Uri.EscapeDataString(assertion)}";
101+
102+
var response = await httpClient.PostAsync(tokenUrl, null);
103+
var responseContent = await response.Content.ReadAsStringAsync();
104+
105+
if (!response.IsSuccessStatusCode)
106+
{
107+
return Results.Json(new { success = false, error = $"Token endpoint returned {response.StatusCode}: {responseContent}" });
108+
}
109+
110+
var tokenResponse = JsonSerializer.Deserialize<TokenResponse>(responseContent);
111+
if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken))
112+
{
113+
return Results.Json(new { success = false, error = "Failed to parse token response" });
114+
}
115+
116+
// Decode the access token (it's base64-encoded JSON in the fake)
117+
var decodedToken = Encoding.UTF8.GetString(Convert.FromBase64String(tokenResponse.AccessToken));
118+
var tokenClaims = JsonSerializer.Deserialize<FakeTokenClaims>(decodedToken);
119+
120+
return Results.Json(new
121+
{
122+
success = true,
123+
claims = tokenClaims
124+
});
125+
}
126+
catch (Exception ex)
127+
{
128+
return Results.Json(new { success = false, error = ex.Message });
129+
}
130+
});
131+
132+
app.Run();
133+
134+
static byte[] Base64UrlDecode(string? input)
135+
{
136+
if (string.IsNullOrEmpty(input))
137+
return Array.Empty<byte>();
138+
139+
// Convert base64url to base64
140+
var base64 = input.Replace('-', '+').Replace('_', '/');
141+
switch (base64.Length % 4)
142+
{
143+
case 2: base64 += "=="; break;
144+
case 3: base64 += "="; break;
145+
}
146+
return Convert.FromBase64String(base64);
147+
}
148+
149+
public class MaskinportenSettings
150+
{
151+
[JsonPropertyName("clientId")]
152+
public string? ClientId { get; set; }
153+
154+
[JsonPropertyName("authority")]
155+
public string? Authority { get; set; }
156+
157+
[JsonPropertyName("jwk")]
158+
public JwkKey? Jwk { get; set; }
159+
}
160+
161+
public class JwkKey
162+
{
163+
[JsonPropertyName("kty")]
164+
public string? Kty { get; set; }
165+
166+
[JsonPropertyName("kid")]
167+
public string? Kid { get; set; }
168+
169+
[JsonPropertyName("n")]
170+
public string? N { get; set; }
171+
172+
[JsonPropertyName("e")]
173+
public string? E { get; set; }
174+
175+
[JsonPropertyName("d")]
176+
public string? D { get; set; }
177+
178+
[JsonPropertyName("p")]
179+
public string? P { get; set; }
180+
181+
[JsonPropertyName("q")]
182+
public string? Q { get; set; }
183+
184+
[JsonPropertyName("dp")]
185+
public string? Dp { get; set; }
186+
187+
[JsonPropertyName("dq")]
188+
public string? Dq { get; set; }
189+
190+
[JsonPropertyName("qi")]
191+
public string? Qi { get; set; }
192+
}
193+
194+
public class TokenResponse
195+
{
196+
[JsonPropertyName("access_token")]
197+
public string? AccessToken { get; set; }
198+
199+
[JsonPropertyName("token_type")]
200+
public string? TokenType { get; set; }
201+
202+
[JsonPropertyName("scope")]
203+
public string? Scope { get; set; }
204+
205+
[JsonPropertyName("expires_in")]
206+
public int ExpiresIn { get; set; }
207+
}
208+
209+
public class FakeTokenClaims
210+
{
211+
[JsonPropertyName("scopes")]
212+
public string[]? Scopes { get; set; }
213+
214+
[JsonPropertyName("client_id")]
215+
public string? ClientId { get; set; }
216+
}
Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
namespace App;
2-
3-
public class Worker : BackgroundService
4-
{
5-
private readonly ILogger<Worker> _logger;
6-
7-
public Worker(ILogger<Worker> logger)
8-
{
9-
_logger = logger;
10-
}
11-
12-
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
13-
{
14-
while (!stoppingToken.IsCancellationRequested)
15-
{
16-
if (_logger.IsEnabled(LogLevel.Information))
17-
{
18-
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
19-
}
20-
await Task.Delay(1000, stoppingToken);
21-
}
22-
}
23-
}
1+
namespace App;
2+
3+
public class Worker : BackgroundService
4+
{
5+
private readonly ILogger<Worker> _logger;
6+
7+
public Worker(ILogger<Worker> logger)
8+
{
9+
_logger = logger;
10+
}
11+
12+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
13+
{
14+
while (!stoppingToken.IsCancellationRequested)
15+
{
16+
if (_logger.IsEnabled(LogLevel.Information))
17+
{
18+
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
19+
}
20+
await Task.Delay(60_000, stoppingToken);
21+
}
22+
}
23+
}

src/Runtime/operator/test/app/App/appsettings.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
},
99
"Logging": {
1010
"LogLevel": {
11-
"Default": "Information",
12-
"Microsoft.Hosting.Lifetime": "Information"
11+
"Default": "Warning",
12+
"Microsoft.Hosting.Lifetime": "Information",
13+
"App": "Information"
1314
}
1415
}
1516
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
[controller Operator should generate token using reconciled credentials - 1]
3+
{
4+
"claims": {
5+
"client_id": "<sanitized-client-id>",
6+
"scopes": [
7+
"altinn:serviceowner/instances.read"
8+
]
9+
},
10+
"success": true
11+
}
12+
---

src/Runtime/operator/test/e2e/e2e_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,36 @@ var _ = Describe("controller", Ordered, func() {
246246
time.Second,
247247
"step1-reconciled",
248248
)
249+
250+
By("triggering pod secret volume sync")
251+
err = TriggerPodSecretSync(k8sClient, clientNamespace, "ttd-localtestapp-deployment")
252+
Expect(err).NotTo(HaveOccurred())
253+
})
254+
255+
It("should generate token using reconciled credentials", func() {
256+
By("calling testapp token endpoint")
257+
var tokenResp *TokenResponse
258+
Eventually(func() error {
259+
resp, err := FetchToken("altinn:serviceowner/instances.read")
260+
if err != nil {
261+
fmt.Fprintf(GinkgoWriter, "FetchToken error: %v\n", err)
262+
return err
263+
}
264+
if !resp.Success {
265+
fmt.Fprintf(GinkgoWriter, "Token request failed: %s\n", resp.Error)
266+
return fmt.Errorf("token request failed: %s", resp.Error)
267+
}
268+
fmt.Fprintf(GinkgoWriter, "Token request succeeded, clientId: %s, scopes: %v\n", resp.Claims.ClientId, resp.Claims.Scopes)
269+
tokenResp = resp
270+
return nil
271+
}, time.Second*10, time.Second).Should(Succeed())
272+
273+
By("verifying token claims")
274+
Expect(tokenResp.Claims).NotTo(BeNil())
275+
Expect(tokenResp.Claims.Scopes).To(ContainElement("altinn:serviceowner/instances.read"))
276+
277+
By("snapshotting token response")
278+
SnapshotTokenResponse(tokenResp, "step1b-token")
249279
})
250280

251281
It("should handle scope removal", func() {
@@ -284,6 +314,10 @@ var _ = Describe("controller", Ordered, func() {
284314
time.Second,
285315
"step2-scope-removed",
286316
)
317+
318+
By("triggering pod secret volume sync")
319+
err = TriggerPodSecretSync(k8sClient, clientNamespace, "ttd-localtestapp-deployment")
320+
Expect(err).NotTo(HaveOccurred())
287321
})
288322

289323
It("should clean up on deletion", func() {

0 commit comments

Comments
 (0)