raindancers-cloudfront 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/lib/bicep/azure-functions/custom-claims-provider/function_app.py +164 -0
  2. package/lib/bicep/azure-functions/custom-claims-provider/host.json +10 -0
  3. package/lib/bicep/azure-functions/custom-claims-provider/requirements.txt +8 -0
  4. package/lib/bicep/deploy/lambda/Dockerfile +10 -0
  5. package/lib/bicep/deploy/lambda/index.py +341 -0
  6. package/lib/bicep/patterns/scripts/LOCALDEBUGREADME.md +86 -0
  7. package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh +122 -0
  8. package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh.bak +118 -0
  9. package/lib/cloudfront/cloudfront-functions/auth-check.js +220 -0
  10. package/lib/cloudfront/cloudfront-functions/modules/auth-check.js +263 -0
  11. package/lib/cloudfront/cloudfront-functions/modules/cognito-auth-check.js +223 -0
  12. package/lib/cloudfront/cloudfront-functions/modules/shared-utils.js +47 -0
  13. package/lib/cloudfront/cloudfront-functions/modules/tls-check.js +39 -0
  14. package/lib/cloudfront/cloudfront-functions/modules/url-rewrite.js +6 -0
  15. package/lib/cloudfront/cloudfront-functions/role-enforcer.js +93 -0
  16. package/lib/cloudfront/cloudfront-functions/tls-enforcer.js +55 -0
  17. package/lib/cloudfront/cloudfront-functions/userinfo-endpoint.js +208 -0
  18. package/lib/cloudfront/lambda/certificate/index.py +219 -0
  19. package/lib/cloudfront/lambda/cognito-auth/oauth-callback.py +215 -0
  20. package/lib/cloudfront/lambda/cognito-auth/requirements.txt +3 -0
  21. package/lib/cloudfront/lambda/edge-auth/config.py +6 -0
  22. package/lib/cloudfront/lambda/edge-auth/config_generated.py +12 -0
  23. package/lib/cloudfront/lambda/edge-auth/oauth-callback.py +538 -0
  24. package/lib/cloudfront/lambda/edge-auth/requirements.txt +3 -0
  25. package/lib/cloudfront/lambda/hmacSecret/index.py +129 -0
  26. package/lib/cloudfront/lambda/hmacSecret/requirements.txt +1 -0
  27. package/lib/cloudfront/lambda/jwt-decoder/index.py +88 -0
  28. package/lib/cloudfront/lambda/pre-token/index.py +11 -0
  29. package/lib/cloudfront/lambda/rotateSecret/index.py +86 -0
  30. package/lib/cloudfront/lambda/session-revocation/index.py +80 -0
  31. package/lib/cloudfront/lambda/ssm-writer/index.py +44 -0
  32. package/lib/cloudfront/lambda/stream-processor/index.py +53 -0
  33. package/lib/cloudfront/lambda/webacl/index.py +356 -0
  34. package/lib/cloudfront/logging/README.md +144 -0
  35. package/lib/cloudfront/patterns/SPLIT_STACK_USAGE.md +138 -0
  36. package/package.json +1 -1
@@ -0,0 +1,164 @@
1
+ import azure.functions as func
2
+ import logging
3
+ import json
4
+ import asyncio
5
+ from azure.identity import DefaultAzureCredential
6
+ from msgraph import GraphServiceClient
7
+
8
+ app = func.FunctionApp()
9
+
10
+ @app.function_name(name="CustomClaimsProvider")
11
+ @app.route(route="CustomClaimsProvider", auth_level=func.AuthLevel.ANONYMOUS)
12
+ async def token_issuance_start(req: func.HttpRequest) -> func.HttpResponse:
13
+ """
14
+ Azure Function for Custom Claims Provider - Token Issuance Start Event
15
+
16
+ Receives token issuance event from Azure AD, queries user's app role assignments,
17
+ and returns separate AWS session tag claims for each role.
18
+ """
19
+ logging.info('Token issuance start event received')
20
+
21
+ try:
22
+ # Parse request body from Azure AD
23
+ req_body = req.get_json()
24
+
25
+ # Extract user ID and application service principal ID from authentication context
26
+ user_id = req_body['data']['authenticationContext']['user']['id']
27
+ app_service_principal_id = req_body['data']['authenticationContext']['clientServicePrincipal']['id']
28
+
29
+ logging.info(f'Processing token issuance for user: {user_id}, app: {app_service_principal_id}')
30
+
31
+ # Get user's app role assignments from Microsoft Graph API
32
+ roles = await get_user_roles(user_id, app_service_principal_id)
33
+
34
+ claims = {}
35
+ if roles:
36
+ claims['Roles'] = ':' + ':'.join(roles) + ':'
37
+ logging.info(f'Emitting Roles claim: {claims.get("Roles", "(empty)")}')
38
+
39
+ # Return response in format Azure AD expects
40
+ response = {
41
+ "data": {
42
+ "@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
43
+ "actions": [
44
+ {
45
+ "@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
46
+ "claims": claims
47
+ }
48
+ ]
49
+ }
50
+ }
51
+
52
+ return func.HttpResponse(
53
+ body=json.dumps(response),
54
+ mimetype="application/json",
55
+ status_code=200
56
+ )
57
+
58
+ except Exception as e:
59
+ logging.error(f'Error processing token issuance event: {str(e)}')
60
+ # Return empty claims on error to allow authentication to proceed
61
+ return create_empty_response()
62
+
63
+
64
+ async def get_user_roles(user_id: str, app_service_principal_id: str) -> list:
65
+ """
66
+ Query Microsoft Graph API to get user's app role assignments for a specific application.
67
+
68
+ Uses Managed Identity (DefaultAzureCredential) for authentication.
69
+ Returns list of role display names (e.g., ["Animals", "PowerUsers"]).
70
+
71
+ Args:
72
+ user_id: User's object ID
73
+ app_service_principal_id: Service principal ID of the application requesting the token
74
+ """
75
+ try:
76
+ # Authenticate using Managed Identity
77
+ credential = DefaultAzureCredential()
78
+ scopes = ['https://graph.microsoft.com/.default']
79
+
80
+ # Create Graph API client
81
+ client = GraphServiceClient(credentials=credential, scopes=scopes)
82
+
83
+ # Query user's app role assignments
84
+ # GET /users/{user-id}/appRoleAssignments
85
+ result = await client.users.by_user_id(user_id).app_role_assignments.get()
86
+
87
+ if not result or not result.value:
88
+ logging.info(f'No app role assignments found for user {user_id}')
89
+ return []
90
+
91
+ # Extract role display names from assignments for THIS application only
92
+ roles = []
93
+ for assignment in result.value:
94
+ # Only process assignments for the application requesting the token
95
+ if str(assignment.resource_id) == app_service_principal_id:
96
+ app_role_id = assignment.app_role_id
97
+
98
+ if app_role_id:
99
+ role_name = await get_role_name(client, app_service_principal_id, app_role_id)
100
+ if role_name:
101
+ roles.append(role_name)
102
+
103
+ logging.info(f'Found {len(roles)} roles for user {user_id}: {roles}')
104
+ return roles
105
+
106
+ except Exception as e:
107
+ logging.error(f'Error querying user roles from Graph API: {str(e)}')
108
+ return []
109
+
110
+
111
+ async def get_role_name(client: GraphServiceClient, resource_id: str, app_role_id: str) -> str:
112
+ """
113
+ Get role display name by querying the service principal's app role definitions.
114
+
115
+ Args:
116
+ client: Graph API client
117
+ resource_id: Service principal object ID
118
+ app_role_id: App role ID (GUID)
119
+
120
+ Returns:
121
+ Role display name (e.g., "Animals") or None if not found
122
+ """
123
+ try:
124
+ # GET /servicePrincipals/{resource-id}
125
+ service_principal = await client.service_principals.by_service_principal_id(resource_id).get()
126
+
127
+ if not service_principal or not service_principal.app_roles:
128
+ return None
129
+
130
+ # Find the app role by ID
131
+ for app_role in service_principal.app_roles:
132
+ if str(app_role.id) == str(app_role_id):
133
+ return app_role.display_name
134
+
135
+ return None
136
+
137
+ except Exception as e:
138
+ logging.error(f'Error getting role name: {str(e)}')
139
+ return None
140
+
141
+
142
+ def create_empty_response() -> func.HttpResponse:
143
+ """
144
+ Create response with empty claims object.
145
+ Used when no roles found or error occurs.
146
+ """
147
+ response = {
148
+ "data": {
149
+ "@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
150
+ "actions": [
151
+ {
152
+ "@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
153
+ "claims": {}
154
+ }
155
+ ]
156
+ }
157
+ }
158
+
159
+ return func.HttpResponse(
160
+ body=json.dumps(response),
161
+ mimetype="application/json",
162
+ status_code=200
163
+ )
164
+
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": "2.0",
3
+ "logging": {
4
+ "applicationInsights": {
5
+ "samplingSettings": {
6
+ "isEnabled": true
7
+ }
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,8 @@
1
+ # Azure Functions runtime
2
+ azure-functions
3
+
4
+ # Azure Identity for Managed Identity authentication
5
+ azure-identity>=1.15.0
6
+
7
+ # Microsoft Graph SDK for Python
8
+ msgraph-sdk>=1.0.0
@@ -0,0 +1,10 @@
1
+ FROM public.ecr.aws/lambda/python:3.12
2
+
3
+ RUN pip install --upgrade boto3 botocore --no-cache-dir && \
4
+ pip install azure-cli --no-cache-dir
5
+
6
+ RUN az bicep install
7
+
8
+ COPY index.py ${LAMBDA_TASK_ROOT}
9
+
10
+ CMD ["index.handler"]
@@ -0,0 +1,341 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import time
5
+ import boto3
6
+ import urllib.request
7
+ import urllib.parse
8
+ import traceback
9
+
10
+ sts = boto3.client('sts')
11
+ s3 = boto3.client('s3')
12
+
13
+ def handler(event, context):
14
+ """Main handler with CloudFormation response handling"""
15
+ response_url = event.get('ResponseURL')
16
+
17
+ try:
18
+ request_type = event['RequestType']
19
+
20
+ if request_type == 'Create':
21
+ result = on_create(event)
22
+ elif request_type == 'Update':
23
+ result = on_update(event)
24
+ elif request_type == 'Delete':
25
+ result = on_delete(event)
26
+ else:
27
+ raise Exception(f'Unknown request type: {request_type}')
28
+
29
+ # Send success response to CloudFormation
30
+ if response_url:
31
+ send_response(event, context, 'SUCCESS', result)
32
+ return result
33
+
34
+ except Exception as e:
35
+ print(f'Error: {str(e)}')
36
+ print(traceback.format_exc())
37
+
38
+ # Send failure response to CloudFormation
39
+ if response_url:
40
+ send_response(event, context, 'FAILED', {
41
+ 'PhysicalResourceId': event.get('PhysicalResourceId', 'NONE'),
42
+ }, reason=str(e)[:3000])
43
+
44
+ raise
45
+
46
+ def send_response(event, context, status, data, reason=None):
47
+ """Send response to CloudFormation"""
48
+ response_body = {
49
+ 'Status': status,
50
+ 'Reason': reason or f'See CloudWatch Log Stream: {context.log_stream_name}',
51
+ 'PhysicalResourceId': data.get('PhysicalResourceId', context.log_stream_name),
52
+ 'StackId': event['StackId'],
53
+ 'RequestId': event['RequestId'],
54
+ 'LogicalResourceId': event['LogicalResourceId'],
55
+ 'Data': data.get('Data', {})
56
+ }
57
+
58
+ json_response = json.dumps(response_body)
59
+ print(f'DEBUG: Response body size: {len(json_response)} bytes')
60
+ print(f'DEBUG: Response body: {json_response[:500]}...') # Log first 500 chars
61
+
62
+ headers = {
63
+ 'Content-Type': '',
64
+ 'Content-Length': str(len(json_response))
65
+ }
66
+
67
+ req = urllib.request.Request(
68
+ event['ResponseURL'],
69
+ data=json_response.encode('utf-8'),
70
+ headers=headers,
71
+ method='PUT'
72
+ )
73
+
74
+ try:
75
+ with urllib.request.urlopen(req) as response:
76
+ print(f'CloudFormation response status: {response.status}')
77
+ except Exception as e:
78
+ print(f'Failed to send response to CloudFormation: {str(e)}')
79
+
80
+ def on_create(event):
81
+ props = event['ResourceProperties']
82
+
83
+ login_azure_federated(
84
+ props['AzureClientId'],
85
+ props['AzureTenantId'],
86
+ props['AzureSubscriptionId']
87
+ )
88
+
89
+ deployment_name = props['DeploymentName']
90
+ resource_group = props['ResourceGroupName']
91
+ template_file = props['TemplateFile']
92
+ parameters = json.loads(props['Parameters'])
93
+
94
+ result = deploy_bicep(deployment_name, resource_group, template_file, parameters)
95
+
96
+ if props.get('FunctionCodeS3Bucket'):
97
+ deploy_function_code(
98
+ s3_bucket=props['FunctionCodeS3Bucket'],
99
+ s3_key=props['FunctionCodeS3Key'],
100
+ function_app_name=result['outputs']['functionAppName'],
101
+ resource_group=resource_group,
102
+ )
103
+
104
+ # Only return essential outputs to stay under CloudFormation 4KB response limit
105
+ essential_outputs = {k: v for k, v in result['outputs'].items() if k in ['appId', 'tenantId']}
106
+
107
+ response_data = {
108
+ 'PhysicalResourceId': deployment_name,
109
+ 'Data': essential_outputs
110
+ }
111
+ print(f'DEBUG: Returning response data: {json.dumps(response_data, indent=2)}')
112
+ return response_data
113
+
114
+ def deploy_function_code(s3_bucket, s3_key, function_app_name, resource_group):
115
+ """Upload zip to Azure blob storage, set WEBSITE_RUN_FROM_PACKAGE to SAS URL"""
116
+ zip_path = '/tmp/function-code.zip'
117
+
118
+ print(f'Downloading function code from s3://{s3_bucket}/{s3_key}')
119
+ s3.download_file(s3_bucket, s3_key, zip_path)
120
+
121
+ # Get storage account name from function app
122
+ result = subprocess.run([
123
+ 'az', 'functionapp', 'show',
124
+ '--name', function_app_name,
125
+ '--resource-group', resource_group,
126
+ '--query', 'storageAccountRequired',
127
+ '--output', 'tsv',
128
+ ], capture_output=True, text=True, check=True)
129
+
130
+ # Get storage account linked to the function app
131
+ result = subprocess.run([
132
+ 'az', 'functionapp', 'config', 'appsettings', 'list',
133
+ '--name', function_app_name,
134
+ '--resource-group', resource_group,
135
+ '--query', "[?name=='AzureWebJobsStorage'].value",
136
+ '--output', 'tsv',
137
+ ], capture_output=True, text=True, check=True)
138
+ conn_str = result.stdout.strip()
139
+
140
+ # Extract account name from connection string
141
+ storage_account_name = next(
142
+ part.split('=', 1)[1] for part in conn_str.split(';') if part.startswith('AccountName=')
143
+ )
144
+
145
+ container = 'function-releases'
146
+ blob_name = f'{function_app_name}.zip'
147
+
148
+ # Create container if it doesn't exist
149
+ subprocess.run([
150
+ 'az', 'storage', 'container', 'create',
151
+ '--name', container,
152
+ '--account-name', storage_account_name,
153
+ ], check=True)
154
+
155
+ # Upload zip
156
+ subprocess.run([
157
+ 'az', 'storage', 'blob', 'upload',
158
+ '--account-name', storage_account_name,
159
+ '--container-name', container,
160
+ '--name', blob_name,
161
+ '--file', zip_path,
162
+ '--overwrite',
163
+ ], check=True)
164
+
165
+ # Generate SAS URL with account key (long expiry)
166
+ result = subprocess.run([
167
+ 'az', 'storage', 'blob', 'generate-sas',
168
+ '--account-name', storage_account_name,
169
+ '--container-name', container,
170
+ '--name', blob_name,
171
+ '--permissions', 'r',
172
+ '--expiry', '2036-01-01T00:00:00Z',
173
+ '--output', 'tsv',
174
+ ], capture_output=True, text=True, check=True)
175
+ sas_token = result.stdout.strip()
176
+
177
+ blob_url = f'https://{storage_account_name}.blob.core.windows.net/{container}/{blob_name}?{sas_token}'
178
+
179
+ # Set WEBSITE_RUN_FROM_PACKAGE
180
+ subprocess.run([
181
+ 'az', 'functionapp', 'config', 'appsettings', 'set',
182
+ '--name', function_app_name,
183
+ '--resource-group', resource_group,
184
+ '--settings', f'WEBSITE_RUN_FROM_PACKAGE={blob_url}',
185
+ ], check=True)
186
+
187
+ print(f'Deployed function code to {function_app_name} via WEBSITE_RUN_FROM_PACKAGE')
188
+
189
+
190
+ def on_update(event):
191
+ return on_create(event)
192
+
193
+ def on_delete(event):
194
+ """Handle delete - if resource never created successfully, just return success"""
195
+ physical_resource_id = event.get('PhysicalResourceId', 'NONE')
196
+
197
+ # If the resource was never created (PhysicalResourceId is NONE or missing),
198
+ # just return success without trying to delete anything
199
+ if not physical_resource_id or physical_resource_id == 'NONE':
200
+ return {'PhysicalResourceId': physical_resource_id}
201
+
202
+ props = event['ResourceProperties']
203
+ deployment_name = physical_resource_id
204
+
205
+ try:
206
+ login_azure_federated(
207
+ props['AzureClientId'],
208
+ props['AzureTenantId'],
209
+ props['AzureSubscriptionId']
210
+ )
211
+
212
+ subprocess.run([
213
+ 'az', 'deployment', 'group', 'delete',
214
+ '--name', deployment_name,
215
+ '--resource-group', props['ResourceGroupName'],
216
+ '--yes'
217
+ ], check=True)
218
+ except Exception as e:
219
+ # Log the error but don't fail the delete
220
+ # CloudFormation should be able to clean up even if Azure delete fails
221
+ print(f'Warning: Failed to delete Azure deployment: {str(e)}')
222
+
223
+ return {'PhysicalResourceId': deployment_name}
224
+
225
+ def login_azure_federated(client_id, tenant_id, subscription_id):
226
+ """Authenticate to Azure using AWS IAM Outbound Identity Federation"""
227
+
228
+ # Set HOME to /tmp so all tools use writable directory
229
+ os.environ['HOME'] = '/tmp'
230
+ # Set Azure CLI config directory to /tmp (Lambda's writable directory)
231
+ os.environ['AZURE_CONFIG_DIR'] = '/tmp/.azure'
232
+ # Set .NET bundle extract directory to /tmp (required for Bicep)
233
+ os.environ['DOTNET_BUNDLE_EXTRACT_BASE_DIR'] = '/tmp'
234
+ # Run Bicep without globalization support (sufficient for IaC)
235
+ os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1'
236
+
237
+ # Use AWS IAM Outbound Identity Federation to get a JWT token
238
+ # This requires the feature to be enabled in the AWS account
239
+ # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound.html
240
+
241
+ try:
242
+ # GetWebIdentityToken requires specific parameters
243
+ response = sts._make_api_call(
244
+ 'GetWebIdentityToken',
245
+ {
246
+ 'Audience': ['api://AzureADTokenExchange'], # Must be a list
247
+ 'DurationSeconds': 3600,
248
+ 'SigningAlgorithm': 'RS256' # Required parameter
249
+ }
250
+ )
251
+
252
+ aws_jwt_token = response['WebIdentityToken']
253
+
254
+ except Exception as e:
255
+ error_msg = str(e)
256
+ if 'Unknown operation' in error_msg or 'InvalidAction' in error_msg:
257
+ raise Exception(
258
+ 'GetWebIdentityToken API not available. '
259
+ 'This requires: 1) boto3 version 1.35.36 or later, '
260
+ '2) Outbound identity federation enabled in your AWS account. '
261
+ 'See: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound_getting_started.html'
262
+ )
263
+ raise Exception(f'Failed to get AWS OIDC token: {error_msg}')
264
+
265
+ # Login to Azure CLI with the AWS JWT token (federated token)
266
+ # The Azure CLI will exchange this token internally
267
+ subprocess.run([
268
+ 'az', 'login',
269
+ '--service-principal',
270
+ '--username', client_id,
271
+ '--tenant', tenant_id,
272
+ '--federated-token', aws_jwt_token
273
+ ], check=True)
274
+
275
+ # Set subscription
276
+ subprocess.run([
277
+ 'az', 'account', 'set',
278
+ '--subscription', subscription_id
279
+ ], check=True)
280
+
281
+ def wait_for_deployments(resource_group, poll_interval=15, timeout=600):
282
+ """Wait until no deployments in the resource group are in Running state"""
283
+ deadline = time.time() + timeout
284
+ while time.time() < deadline:
285
+ result = subprocess.run([
286
+ 'az', 'deployment', 'group', 'list',
287
+ '--resource-group', resource_group,
288
+ '--query', "[?properties.provisioningState=='Running'].name",
289
+ '--output', 'json'
290
+ ], capture_output=True, text=True, check=True)
291
+ running = json.loads(result.stdout)
292
+ if not running:
293
+ return
294
+ print(f'Waiting for running deployments: {running}')
295
+ time.sleep(poll_interval)
296
+ raise Exception(f'Timed out waiting for running deployments in {resource_group}')
297
+
298
+ def deploy_bicep(name, resource_group, template_file, parameters):
299
+ # Write template content to a file in /tmp
300
+ template_path = f'/tmp/{name}.bicep'
301
+ with open(template_path, 'w') as f:
302
+ f.write(template_file)
303
+
304
+ param_args = []
305
+ for key, value in parameters.items():
306
+ param_args.extend(['--parameters', f'{key}={value}'])
307
+
308
+ cmd = [
309
+ 'az', 'deployment', 'group', 'create',
310
+ '--name', name,
311
+ '--resource-group', resource_group,
312
+ '--template-file', template_path,
313
+ *param_args,
314
+ '--query', '{outputs:properties.outputs}',
315
+ '--output', 'json'
316
+ ]
317
+
318
+ result = subprocess.run(cmd, capture_output=True, text=True)
319
+
320
+ if result.returncode != 0 and 'DeploymentActive' in result.stderr:
321
+ print('DeploymentActive detected — waiting for running deployments to finish')
322
+ wait_for_deployments(resource_group)
323
+ result = subprocess.run(cmd, capture_output=True, text=True)
324
+
325
+ if result.returncode != 0:
326
+ error_msg = f'Bicep deployment failed.\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}'
327
+ print(error_msg)
328
+ raise Exception(error_msg)
329
+
330
+ deployment = json.loads(result.stdout)
331
+ print(f'DEBUG: Deployment response: {json.dumps(deployment, indent=2)}')
332
+
333
+ outputs = {}
334
+ deployment_outputs = deployment.get('outputs', {})
335
+ print(f'DEBUG: Deployment outputs: {json.dumps(deployment_outputs, indent=2)}')
336
+
337
+ for key, value in deployment_outputs.items():
338
+ outputs[key] = value['value']
339
+
340
+ print(f'DEBUG: Extracted outputs: {json.dumps(outputs, indent=2)}')
341
+ return {'outputs': outputs}
@@ -0,0 +1,86 @@
1
+ # Local Debugging: wire-custom-claims-extension.sh
2
+
3
+ ## How the script runs in deployment vs locally
4
+
5
+ ### Deployment mode (default)
6
+ In deployment, this script runs inside an Azure `DeploymentScript` resource, executed by a managed identity that has been granted the required Graph application permissions. The managed identity authenticates via the Azure CLI (`az account get-access-token`), which works automatically in that container environment.
7
+
8
+ ### Local mode
9
+ Locally, the `az` CLI uses your personal delegated token, which does **not** have the Graph application permissions (`CustomAuthenticationExtension.ReadWrite.All`, `EventListener.ReadWrite.All`, `Policy.ReadWrite.ApplicationConfiguration`, `Policy.Read.All`, `Application.ReadWrite.All`) needed by this script. You must use a service principal with those permissions granted via `client_credentials` instead.
10
+
11
+ ## Running locally
12
+
13
+ ### Step 1: Create a test service principal
14
+
15
+ Run the setup script once to create an app registration with the required Graph application permissions:
16
+
17
+ ```bash
18
+ bash scripts/setup-test-sp.sh
19
+ ```
20
+
21
+ This outputs `TEST_TENANT_ID`, `TEST_CLIENT_ID`, and `TEST_CLIENT_SECRET`. Export them:
22
+
23
+ ```bash
24
+ export TEST_TENANT_ID=<value>
25
+ export TEST_CLIENT_ID=<value>
26
+ export TEST_CLIENT_SECRET=<value>
27
+ ```
28
+
29
+ ### Step 2: Set the script's required environment variables
30
+
31
+ These are the same env vars the `DeploymentScript` injects at deployment time. Get the values from the deployed Azure resources:
32
+
33
+ ```bash
34
+ export FUNCTION_HOSTNAME="<function-app-hostname>" # e.g. clf-claims-func-6qx5xi3p.azurewebsites.net
35
+ export FUNCTION_APP_ID="<function-app-registration-appId>"
36
+ export CLF_APP_ID="<clf-app-registration-appId>"
37
+ export EXT_DISPLAY_NAME="<namePrefix>-claims-provider" # e.g. clf-claims-provider
38
+ ```
39
+
40
+ ### Step 3: Run the script
41
+
42
+ ```bash
43
+ bash src/constructsForPackaging/cdk-bicep/src/patterns/scripts/wire-custom-claims-extension.sh
44
+ ```
45
+
46
+ When `TEST_CLIENT_ID` is set, the script automatically uses `client_credentials` to obtain a token. When it is not set (deployment), it uses `az account get-access-token`.
47
+
48
+ ### Step 4: Clean up the test service principal
49
+
50
+ ```bash
51
+ az ad app delete --id $TEST_CLIENT_ID
52
+ ```
53
+
54
+ ## Why we use curl instead of az rest
55
+
56
+ Microsoft's recommended pattern for calling Graph API from Azure CLI DeploymentScripts is `az rest`, which handles authentication automatically using the managed identity context. However, this script uses raw `curl` with manual token management for a specific reason: the permission propagation retry loop.
57
+
58
+ The retry loop acquires a token, decodes the JWT payload, and inspects the `roles` claim to verify all required Graph permissions have propagated before proceeding. `az rest` acquires its own token internally and doesn't expose it, making JWT inspection impossible. Manual token management via `az account get-access-token` is therefore required to support this retry logic.
59
+
60
+ If the propagation retry is ever removed, the script should be refactored to use `az rest`.
61
+
62
+ ## Why delegated az CLI tokens don't work
63
+
64
+ The `az` CLI authenticates as the Microsoft Azure CLI app registration, which has not been granted the required Graph **application** permissions in your tenant. You will see errors like `"The application does not have any of the required delegated permissions"`. The test SP uses `client_credentials` which grants application permissions — exactly replicating what the managed identity does in deployment.
65
+
66
+ ## Known issue: `set -x` causes deployment failures
67
+
68
+ Do **not** add `set -x` to this script for deployment debugging. The script acquires a Graph JWT (~2KB) and `set -x` prints every command with its full arguments, including `TOKEN=<full JWT>` and `Authorization: Bearer <full JWT>` on every curl call. With 6 retry iterations this easily exceeds Azure's 256KB aggregated error message limit, causing the deployment to fail with:
69
+
70
+ ```
71
+ The aggregated deployment error is too large. Please list deployment operations to get the deployment details.
72
+ ```
73
+
74
+ To debug deployment failures, use `az deployment-scripts show-log` after the script runs, or add targeted `echo` statements instead of `set -x`.
75
+
76
+ ## Known issue: permission propagation race condition
77
+
78
+ The managed identity's Graph app role assignments (`Policy.Read.All`, `Policy.ReadWrite.ApplicationConfiguration`, `Application.ReadWrite.All`, etc.) are created in the same Bicep deployment as the DeploymentScript. Entra may not have propagated these permissions by the time the script runs, causing the token to be missing required roles.
79
+
80
+ The script handles this with a retry loop at startup: it acquires a token, decodes the JWT, checks all required roles are present, and retries up to 6 times with 20s gaps (120s total) before failing. You will see output like:
81
+
82
+ ```
83
+ Waiting for role propagation (attempt 1/6), missing: Policy.Read.All Policy.ReadWrite.ApplicationConfiguration
84
+ ```
85
+
86
+ This is expected on fresh deployments. If it fails after 120s, check the managed identity's app role assignments in Entra.