Skip to content

Commit 26ac1b3

Browse files
committed
feat: working entra ID implementation
Signed-off-by: copasseron <cpassero@cisco.com>
1 parent bbd78af commit 26ac1b3

4 files changed

Lines changed: 237 additions & 63 deletions

File tree

backend/internal/core/identity/identity_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ func (s *service) generateProof(
317317
// Respect context cancellation and cap total wait time.
318318
select {
319319
case <-ctx.Done():
320-
return "", ctx.Err()
320+
return nil, ctx.Err()
321321
case <-time.After(delay):
322322
}
323323

backend/internal/core/idp/entra_idp.go

Lines changed: 208 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
const (
2424
graphScope = "https://graph.microsoft.com/.default"
2525
passwordDisplayName = "identity-service"
26-
passwordLifetime = 365 * 24 * time.Hour
2726
)
2827

2928
type EntraIdp struct {
@@ -61,33 +60,97 @@ func (e *EntraIdp) TestSettings(ctx context.Context) error {
6160
}
6261

6362
func (e *EntraIdp) CreateClientCredentialsPair(ctx context.Context) (*ClientCredentials, error) {
64-
log.FromContext(ctx).Debug("Creating client credentials pair for Entra ID using static settings (Graph scope)")
63+
logger := log.FromContext(ctx)
64+
logger.Debug("Creating client credentials pair for Entra ID using dynamic app provisioning")
6565

6666
if err := e.validateSettings(); err != nil {
6767
return nil, err
6868
}
6969

70+
client, err := e.graphClient()
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to initialize Microsoft Graph client: %w", err)
73+
}
74+
75+
app, secret, err := e.createApplication(ctx, client)
76+
if err != nil {
77+
return nil, fmt.Errorf("failed to create Entra application: %w", err)
78+
}
79+
80+
if app == nil || app.GetId() == nil || app.GetAppId() == nil {
81+
return nil, errors.New("application response is missing identifiers")
82+
}
83+
84+
appObjectID := *app.GetId()
85+
appID := *app.GetAppId()
86+
7087
issuer := fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", e.settings.TenantID)
88+
scopes := []string{fmt.Sprintf("api://%s/.default", appID)}
7189

72-
log.FromContext(ctx).
73-
WithField("client_id", e.settings.ClientID).
90+
logger.
91+
WithField("app_id", appID).
92+
WithField("app_object_id", appObjectID).
7493
WithField("issuer", issuer).
75-
WithField("scopes_count", len([]string{graphScope})).
76-
Debug("Entra client credentials prepared from settings")
94+
WithField("scopes_count", len(scopes)).
95+
Info("Entra client credentials created via dynamic app provisioning")
7796

7897
return &ClientCredentials{
79-
ClientID: e.settings.ClientID,
80-
ClientSecret: e.settings.ClientSecret,
98+
ClientID: appID,
99+
ClientSecret: secret,
81100
Issuer: issuer,
82-
Scopes: []string{graphScope},
101+
Scopes: scopes,
83102
}, nil
84103
}
85104

86105
func (e *EntraIdp) DeleteClientCredentialsPair(ctx context.Context, clientCredentials *ClientCredentials) error {
87-
// When using static client credentials from settings, there is nothing
88-
// to clean up in Entra. Keep this a no-op to avoid accidentally
89-
// deleting a manually managed application registration.
90-
log.FromContext(ctx).Debug("DeleteClientCredentialsPair is a no-op for Entra ID static credentials")
106+
logger := log.FromContext(ctx)
107+
logger.Debug("Deleting client credentials pair for Entra ID")
108+
109+
if clientCredentials == nil || clientCredentials.ClientID == "" {
110+
return errors.New("client credentials are not provided or client_id is empty")
111+
}
112+
113+
if err := e.validateSettings(); err != nil {
114+
return err
115+
}
116+
117+
client, err := e.graphClient()
118+
if err != nil {
119+
return fmt.Errorf("failed to initialize Microsoft Graph client: %w", err)
120+
}
121+
122+
// Best-effort delete: application first, then service principal. Missing
123+
// resources are treated as successful deletion.
124+
appObjectID, err := e.lookupApplicationObjectID(ctx, client, clientCredentials.ClientID)
125+
if err != nil {
126+
return fmt.Errorf("failed to lookup application object ID: %w", err)
127+
}
128+
129+
if appObjectID != "" {
130+
logger.WithField("app_object_id", appObjectID).Debug("Deleting Entra application")
131+
if err := client.Applications().ByApplicationId(appObjectID).Delete(ctx, nil); err != nil {
132+
if !isNotFoundError(err) {
133+
return fmt.Errorf("failed to delete application: %w", err)
134+
}
135+
logger.WithError(err).WithField("app_object_id", appObjectID).Debug("Entra application already deleted")
136+
}
137+
}
138+
139+
spObjectID, err := e.lookupServicePrincipalObjectID(ctx, client, clientCredentials.ClientID)
140+
if err != nil {
141+
return fmt.Errorf("failed to lookup service principal object ID: %w", err)
142+
}
143+
144+
if spObjectID != "" {
145+
logger.WithField("service_principal_id", spObjectID).Debug("Deleting Entra service principal")
146+
if err := client.ServicePrincipals().ByServicePrincipalId(spObjectID).Delete(ctx, nil); err != nil {
147+
if !isNotFoundError(err) {
148+
return fmt.Errorf("failed to delete service principal: %w", err)
149+
}
150+
logger.WithError(err).WithField("service_principal_id", spObjectID).Debug("Entra service principal already deleted")
151+
}
152+
}
153+
91154
return nil
92155
}
93156

@@ -122,7 +185,7 @@ func (e *EntraIdp) graphClient() (*msgraphsdkgo.GraphServiceClient, error) {
122185
return client, nil
123186
}
124187

125-
func (e *EntraIdp) createApplication(ctx context.Context, client *msgraphsdkgo.GraphServiceClient) (models.Applicationable, error) {
188+
func (e *EntraIdp) createApplication(ctx context.Context, client *msgraphsdkgo.GraphServiceClient) (models.Applicationable, string, error) {
126189
displayName := getName()
127190
signInAudience := "AzureADMyOrg"
128191
requestedAccessTokenVersion := int32(2)
@@ -135,54 +198,42 @@ func (e *EntraIdp) createApplication(ctx context.Context, client *msgraphsdkgo.G
135198
apiConfig.SetRequestedAccessTokenVersion(&requestedAccessTokenVersion)
136199
app.SetApi(apiConfig)
137200

201+
// Configure an initial password credential so that the application is
202+
// immediately usable with client-credentials flow and the secret is
203+
// returned in the create response (secretText).
204+
passwordCredential := models.NewPasswordCredential()
205+
passwordName := passwordDisplayName
206+
passwordCredential.SetDisplayName(&passwordName)
207+
passwordCredentials := []models.PasswordCredentialable{passwordCredential}
208+
app.SetPasswordCredentials(passwordCredentials)
209+
138210
log.FromContext(ctx).
139211
WithField("requested_access_token_version", requestedAccessTokenVersion).
140212
WithField("display_name", displayName).
141213
Debug("Creating Entra application with v2.0 token version")
142214

143215
createdApp, err := client.Applications().Post(ctx, app, nil)
144216
if err != nil {
145-
return nil, fmt.Errorf("graph applications post failed: %w", err)
217+
return nil, "", fmt.Errorf("graph applications post failed: %w", err)
146218
}
147219

148220
if createdApp == nil || createdApp.GetId() == nil || createdApp.GetAppId() == nil {
149-
return nil, errors.New("application response is missing identifiers")
221+
return nil, "", errors.New("application response is missing identifiers")
150222
}
151223

152-
// Update Application ID URI with the actual appId (needed for app-scoped tokens)
224+
// Extract the generated secret from the password credentials.
225+
creds := createdApp.GetPasswordCredentials()
226+
if len(creds) == 0 || creds[0] == nil || creds[0].GetSecretText() == nil || *creds[0].GetSecretText() == "" {
227+
return nil, "", errors.New("application response did not include password secret")
228+
}
229+
230+
// Update Application ID URI with the actual appId (needed for app-scoped tokens).
153231
appID := *createdApp.GetAppId()
154232
objectID := *createdApp.GetId()
155233

156234
updateApp := models.NewApplication()
157235
updateApp.SetIdentifierUris([]string{fmt.Sprintf("api://%s", appID)})
158236

159-
updatedApp, err := client.Applications().ByApplicationId(objectID).Patch(ctx, updateApp, nil)
160-
if err != nil {
161-
log.FromContext(ctx).WithError(err).Warn("Failed to set Application ID URI, continuing anyway")
162-
return createdApp, nil
163-
}
164-
165-
return updatedApp, nil
166-
}
167-
168-
func (e *EntraIdp) addPasswordCredential(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, applicationID string) (string, error) {
169-
if applicationID == "" {
170-
return "", errors.New("application object ID is empty")
171-
}
172-
173-
now := time.Now().UTC()
174-
end := now.Add(passwordLifetime)
175-
176-
passwordCredential := models.NewPasswordCredential()
177-
displayName := passwordDisplayName
178-
passwordCredential.SetDisplayName(&displayName)
179-
passwordCredential.SetStartDateTime(&now)
180-
passwordCredential.SetEndDateTime(&end)
181-
182-
requestBody := applications.NewItemAddPasswordPostRequestBody()
183-
requestBody.SetPasswordCredential(passwordCredential)
184-
185-
// Keep retry window within extended gRPC deadline (30s) to handle Microsoft Entra replication lag
186237
const (
187238
maxAttempts = 8
188239
delay = 3 * time.Second
@@ -194,45 +245,50 @@ func (e *EntraIdp) addPasswordCredential(ctx context.Context, client *msgraphsdk
194245
if attempt > 0 {
195246
select {
196247
case <-ctx.Done():
197-
return "", ctx.Err()
248+
return nil, "", ctx.Err()
198249
case <-time.After(delay):
199250
}
200251
}
201252

202-
response, err := client.Applications().ByApplicationId(applicationID).AddPassword().Post(ctx, requestBody, nil)
253+
updatedApp, err := client.Applications().ByApplicationId(objectID).Patch(ctx, updateApp, nil)
203254
if err == nil {
204-
if response == nil || response.GetSecretText() == nil || *response.GetSecretText() == "" {
205-
return "", errors.New("password response did not include secret text")
206-
}
207-
208-
secretText := *response.GetSecretText()
209255
log.FromContext(ctx).
256+
WithField("application_id", objectID).
257+
WithField("app_id_uri", fmt.Sprintf("api://%s", appID)).
210258
WithField("attempt", attempt+1).
211-
WithField("application_id", applicationID).
212-
WithField("secret_length", len(secretText)).
213-
Info("Entra addPassword succeeded")
259+
Info("Entra Application ID URI set successfully")
260+
261+
// Ensure a corresponding service principal exists for this
262+
// application so that client-credentials flow can succeed
263+
// without relying on implicit provisioning.
264+
if err := e.ensureServicePrincipal(ctx, client, appID); err != nil {
265+
return nil, "", fmt.Errorf("failed to ensure service principal: %w", err)
266+
}
214267

215-
return secretText, nil
268+
// Return the originally created application object, which is
269+
// guaranteed to have both Id and AppId populated.
270+
_ = updatedApp // updatedApp is not used beyond confirming success.
271+
return createdApp, *creds[0].GetSecretText(), nil
216272
}
217273

218274
lastErr = err
219275

220276
log.FromContext(ctx).
221277
WithError(err).
278+
WithField("application_id", objectID).
222279
WithField("attempt", attempt+1).
223-
WithField("application_id", applicationID).
224-
Warn("Entra addPassword attempt failed")
280+
Warn("Failed to set Application ID URI, retrying if propagation pending")
225281

226282
if !isPropagationPendingError(err) {
227-
return "", fmt.Errorf("graph addPassword post failed: %w", err)
283+
break
228284
}
229285
}
230286

231-
if lastErr == nil {
232-
return "", errors.New("password creation failed without error details")
287+
if lastErr != nil {
288+
return nil, "", fmt.Errorf("failed to set Application ID URI after %d attempts: %w", maxAttempts, lastErr)
233289
}
234290

235-
return "", fmt.Errorf("graph addPassword post failed after %d attempts: %w", maxAttempts, lastErr)
291+
return createdApp, *creds[0].GetSecretText(), nil
236292
}
237293

238294
func isPropagationPendingError(err error) bool {
@@ -254,6 +310,97 @@ func isPropagationPendingError(err error) bool {
254310
}
255311
}
256312

313+
func isNotFoundError(err error) bool {
314+
if err == nil {
315+
return false
316+
}
317+
318+
msg := err.Error()
319+
320+
if strings.Contains(msg, "does not exist") || strings.Contains(msg, "Request_ResourceNotFound") {
321+
return true
322+
}
323+
324+
return false
325+
}
326+
327+
// ensureServicePrincipal guarantees that a service principal exists for the
328+
// given appID. This is required for the client-credentials flow to work,
329+
// otherwise Entra returns AADSTS7000229 (missing service principal) or
330+
// related invalid_client errors.
331+
func (e *EntraIdp) ensureServicePrincipal(
332+
ctx context.Context,
333+
client *msgraphsdkgo.GraphServiceClient,
334+
appID string,
335+
) error {
336+
logger := log.FromContext(ctx)
337+
338+
if appID == "" {
339+
return errors.New("appID is empty when ensuring service principal")
340+
}
341+
342+
// If a service principal already exists, nothing to do.
343+
if existingID, err := e.lookupServicePrincipalObjectID(ctx, client, appID); err != nil {
344+
return fmt.Errorf("failed to lookup existing service principal: %w", err)
345+
} else if existingID != "" {
346+
logger.WithField("service_principal_id", existingID).Debug("Service principal already exists for application")
347+
return nil
348+
}
349+
350+
sp := models.NewServicePrincipal()
351+
sp.SetAppId(&appID)
352+
353+
const (
354+
maxAttempts = 8
355+
delay = 3 * time.Second
356+
)
357+
358+
var lastErr error
359+
360+
for attempt := 0; attempt < maxAttempts; attempt++ {
361+
if attempt > 0 {
362+
select {
363+
case <-ctx.Done():
364+
return ctx.Err()
365+
case <-time.After(delay):
366+
}
367+
}
368+
369+
createdSp, err := client.ServicePrincipals().Post(ctx, sp, nil)
370+
if err == nil {
371+
id := ""
372+
if createdSp != nil && createdSp.GetId() != nil {
373+
id = *createdSp.GetId()
374+
}
375+
376+
logger.
377+
WithField("service_principal_id", id).
378+
WithField("app_id", appID).
379+
WithField("attempt", attempt+1).
380+
Info("Service principal created for Entra application")
381+
return nil
382+
}
383+
384+
lastErr = err
385+
386+
logger.
387+
WithError(err).
388+
WithField("app_id", appID).
389+
WithField("attempt", attempt+1).
390+
Warn("Failed to create service principal, retrying if propagation pending")
391+
392+
if !isPropagationPendingError(err) {
393+
break
394+
}
395+
}
396+
397+
if lastErr != nil {
398+
return fmt.Errorf("failed to create service principal after %d attempts: %w", maxAttempts, lastErr)
399+
}
400+
401+
return nil
402+
}
403+
257404
func (e *EntraIdp) lookupApplicationObjectID(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, clientID string) (string, error) {
258405
if clientID == "" {
259406
return "", nil

0 commit comments

Comments
 (0)