Skip to content

Commit c0fd33f

Browse files
committed
feat: add client-credentials OAuth flow for API testing
1 parent b21a66d commit c0fd33f

6 files changed

Lines changed: 97 additions & 2 deletions

File tree

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ services:
642642
KAFKA__SCHEMAREGISTRY: "http://kafka:8082"
643643
KAFKA__GROUPID: "stickerlandia-user-management"
644644
DEPLOYMENT_HOST_URL: ${DEPLOYMENT_HOST_URL}
645+
APITESTING_CLIENT_SECRET: stickerlandia-api-testing-secret-2025
645646
command: ["migrations/Stickerlandia.UserManagement.MigrationService.dll"]
646647

647648
web-backend:

user-management/infra/aws/lib/user-service-stack.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { MigrationTask } from "../../../../shared/lib/shared-constructs/lib/migration-task";
1616
import { Api } from "./api";
1717
import { Cluster, Secret } from "aws-cdk-lib/aws-ecs";
18+
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
1819
import * as path from "path";
1920
import { BackgroundWorkers } from "./background-workers";
2021
import { StringParameter } from "aws-cdk-lib/aws-ssm";
@@ -73,6 +74,19 @@ export class UserServiceStack extends cdk.Stack {
7374
createSsmParameterReferences: false,
7475
});
7576

77+
// Secret for the api-testing OAuth client (used by Datadog Synthetics)
78+
const apiTestingClientSecret = new secretsmanager.Secret(
79+
this,
80+
"ApiTestingClientSecret",
81+
{
82+
secretName: `/stickerlandia/${environment}/api-testing-client-secret`,
83+
generateSecretString: {
84+
excludePunctuation: true,
85+
passwordLength: 48,
86+
},
87+
},
88+
);
89+
7690
// Run database migrations before starting services
7791
const migrationTask = new MigrationTask(this, "MigrationTask", {
7892
sharedProps: sharedProps,
@@ -98,6 +112,9 @@ export class UserServiceStack extends cdk.Stack {
98112
secrets: {
99113
ConnectionStrings__database:
100114
dbCredentials.getConnectionStringEcsSecret()!,
115+
APITESTING_CLIENT_SECRET: Secret.fromSecretsManager(
116+
apiTestingClientSecret,
117+
),
101118
},
102119
// Use the same security group as the API service for consistent network access
103120
securityGroupId: sharedResources.vpcLinkSecurityGroupId,
@@ -161,5 +178,11 @@ export class UserServiceStack extends cdk.Stack {
161178
value: `${sharedResources.domainName}/api/users/v1`,
162179
description: "User Management Service API URL",
163180
});
181+
182+
new cdk.CfnOutput(this, "ApiTestingClientSecretArn", {
183+
value: apiTestingClientSecret.secretArn,
184+
description:
185+
"ARN of the Secrets Manager secret for the api-testing OAuth client",
186+
});
164187
}
165188
}

user-management/infra/azure/variables.tf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,17 @@ variable "dd_api_key" {
3030
variable "dd_site" {
3131
default = "datadoghq.com"
3232
description = "The Datadog site"
33+
}
34+
35+
# Secret for the api-testing OAuth client (used by Datadog Synthetics).
36+
# Inject as APITESTING_CLIENT_SECRET into the migration service environment.
37+
resource "random_password" "api_testing_client_secret" {
38+
length = 48
39+
special = false
40+
}
41+
42+
output "api_testing_client_secret" {
43+
value = random_password.api_testing_client_secret.result
44+
sensitive = true
45+
description = "Secret for the api-testing OAuth client (pass to Datadog Synthetics)"
3346
}

user-management/src/Stickerlandia.UserManagement.Api/Controllers/AuthorizationController.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,30 @@ public async Task<IActionResult> Exchange()
289289
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
290290
}
291291

292+
if (request.IsClientCredentialsGrantType())
293+
{
294+
// Client credentials flow: application-level authentication (no user context).
295+
// Used for service-to-service calls such as Datadog Synthetics API testing.
296+
var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ??
297+
throw new InvalidOperationException("The client application cannot be found.");
298+
299+
var identity = new ClaimsIdentity(
300+
TokenValidationParameters.DefaultAuthenticationType,
301+
OpenIddictConstants.Claims.Name,
302+
OpenIddictConstants.Claims.Role);
303+
304+
identity.SetClaim(OpenIddictConstants.Claims.Subject, request.ClientId);
305+
identity.SetClaim(OpenIddictConstants.Claims.Name, request.ClientId);
306+
identity.SetClaims(OpenIddictConstants.Claims.Role, ["api-testing"]);
307+
308+
identity.SetScopes(request.GetScopes());
309+
identity.SetResources(await scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
310+
identity.SetAudiences("stickerlandia");
311+
identity.SetDestinations(GetDestinations);
312+
313+
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
314+
}
315+
292316
throw new InvalidOperationException("The specified grant type is not supported.");
293317
}
294318

user-management/src/Stickerlandia.UserManagement.Auth/AuthServiceExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ public static IServiceCollection AddCoreAuthentication(this IServiceCollection s
4747
// the other flows if you need to support implicit, password or client credentials.
4848
options.AllowAuthorizationCodeFlow()
4949
.RequireProofKeyForCodeExchange()
50-
.AllowRefreshTokenFlow();
50+
.AllowRefreshTokenFlow()
51+
.AllowClientCredentialsFlow();
5152

5253
// Register the signing and encryption credentials.
5354
options.AddDevelopmentEncryptionCertificate()

user-management/src/Stickerlandia.UserManagement.MigrationService/Worker.cs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,40 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor
131131
seedData?.SetTag("oauth.web-ui.redirectUri", redirectUri.ToString());
132132
}
133133

134-
// As soon as Stickerlandia services need to call other services under their own identities, add them here
134+
// api-testing client: used for service-to-service calls (e.g. Datadog Synthetics API testing).
135+
// Uses the client_credentials OAuth flow — no user context, no redirect URIs.
136+
var apiTestingSecret = Environment.GetEnvironmentVariable("APITESTING_CLIENT_SECRET")
137+
?? "stickerlandia-api-testing-secret-2025";
138+
139+
var existingApiTestingClient = await manager.FindByClientIdAsync("api-testing", cancellationToken);
140+
if (existingApiTestingClient is null)
141+
{
142+
await manager.CreateAsync(new OpenIddictApplicationDescriptor
143+
{
144+
ClientId = "api-testing",
145+
ClientSecret = apiTestingSecret,
146+
ClientType = OpenIddictConstants.ClientTypes.Confidential,
147+
ConsentType = OpenIddictConstants.ConsentTypes.Implicit,
148+
Permissions =
149+
{
150+
OpenIddictConstants.Permissions.Endpoints.Token,
151+
OpenIddictConstants.Permissions.GrantTypes.ClientCredentials,
152+
OpenIddictConstants.Permissions.Scopes.Roles
153+
}
154+
}, cancellationToken);
155+
156+
seedData?.SetTag("oauth.api-testing.created", true);
157+
}
158+
else
159+
{
160+
// Update existing client to ensure secret matches (cloud-provided secret may have changed)
161+
var descriptor = new OpenIddictApplicationDescriptor();
162+
await manager.PopulateAsync(descriptor, existingApiTestingClient, cancellationToken);
163+
descriptor.ClientSecret = apiTestingSecret;
164+
await manager.UpdateAsync(existingApiTestingClient, descriptor, cancellationToken);
165+
166+
seedData?.SetTag("oauth.api-testing.updated", true);
167+
}
135168

136169
// Seed default user
137170
try

0 commit comments

Comments
 (0)