@@ -23,7 +23,6 @@ import (
2323const (
2424 graphScope = "https://graph.microsoft.com/.default"
2525 passwordDisplayName = "identity-service"
26- passwordLifetime = 365 * 24 * time .Hour
2726)
2827
2928type EntraIdp struct {
@@ -61,33 +60,97 @@ func (e *EntraIdp) TestSettings(ctx context.Context) error {
6160}
6261
6362func (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
86105func (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
238294func 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+
257404func (e * EntraIdp ) lookupApplicationObjectID (ctx context.Context , client * msgraphsdkgo.GraphServiceClient , clientID string ) (string , error ) {
258405 if clientID == "" {
259406 return "" , nil
0 commit comments