Skip to content

Commit 5325768

Browse files
emmanuelknafoCopilot
andcommitted
feat: enhance README and add app.json for multi-tenant SPA configuration
Co-authored-by: Copilot <copilot@github.com>
1 parent 47a4930 commit 5325768

2 files changed

Lines changed: 177 additions & 20 deletions

File tree

README.md

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -427,40 +427,91 @@ msal-java/
427427

428428
## Multi-tenant App
429429

430-
The single-tenant `Evidence Portal SPA` registration is the one the workshop deploys by default. Alongside it, a multi-tenant counterpart — `Evidence Portal Multi-Tenant SPA` (client id `0713f130-110b-4982-9ce3-8c9227935ca0`) — has been registered in the home tenant so external Entra ID tenants can be onboarded incrementally. It may eventually replace the single-tenant app once the API and SPA are wired for multi-tenant token validation.
430+
The single-tenant `Evidence Portal SPA` registration is the one the workshop deploys by default. Alongside it, a multi-tenant counterpart — `Evidence Portal Multi-Tenant SPA` — has been registered in the home tenant so external Entra ID tenants can be onboarded incrementally. It may eventually replace the single-tenant app once the API and SPA are wired for multi-tenant token validation.
431+
432+
### Identifiers (verified 2026-05-01)
433+
434+
| Item | Value |
435+
| --- | --- |
436+
| Application (client) id | `0713f130-110b-4982-9ce3-8e9227935ca0` |
437+
| Application object id | `25027aaf-05ce-4257-a574-405a50d91a46` |
438+
| Display name | `Evidence Portal Multi-Tenant SPA` |
439+
| `signInAudience` | `AzureADMultipleOrgs` |
440+
| Home tenant id | `aa93b9d9-037d-4f08-a26d-783cff0e2369` (`MngEnvMCAP675646.onmicrosoft.com` — surfaced in the portal as *Contoso*) |
441+
442+
> Always verify the AppId by reading the manifest, not by re-typing from the portal address bar. `c` and `e` are visually almost identical in the portal URL and a one-character transposition causes hours of `AADSTS700016` ("application not found") chasing. Quick check from the home tenant:
443+
>
444+
> ```powershell
445+
> az rest --method GET `
446+
> --uri "https://graph.microsoft.com/v1.0/applications/25027aaf-05ce-4257-a574-405a50d91a46" `
447+
> --query "{name:displayName, appId:appId, audience:signInAudience}" -o table
448+
> ```
431449
432450
### Onboard a tenant
433451
434-
Two things must be true before users in tenant **T** can sign in:
452+
Three things must be true before users in tenant **T** can sign in:
435453
436-
1. **T is on the allow-list.** On the registration's *Authentication → Supported accounts* blade, *Allow only certain tenants (Preview)* is selected and T's tenant id is listed under *Allowed tenants*. The home tenant where the app is registered is always allowed implicitly.
437-
2. **A service principal for the app exists in T.** Until a user signs in or an admin consents in T, there is no service principal there, no user/group assignment is possible, and no app role can be granted.
454+
1. **A service principal for the app exists in T.** Until a user signs in or an admin consents in T, there is no service principal there, no user/group assignment is possible, and no app role can be granted.
455+
2. **The app object has at least one SPA redirect URI** registered on the home-tenant registration. Without one, the post-consent redirect fails with `AADSTS500113: No reply address is registered for the application` even though the consent itself succeeded.
456+
3. *(Optional — only if the registration is gated by *Allow only certain tenants (Preview)*)* T's tenant id is listed under *Allowed tenants*. The home tenant is always allowed implicitly. The current registration is **not** gated, so this requirement is currently dormant.
438457
439-
`devopsabcs.com` (`a34c69c7-8959-474a-9690-e98bfb0b55c6`) has already been added to the allow-list. Currently allowed tenants:
458+
`devopsabcs.com` (`a34c69c7-8959-474a-9690-e98bfb0b55c6`) has been onboarded successfully. Currently consented tenants:
440459
441460
| Tenant id | Notes |
442461
| --- | --- |
443-
| `aa93b9d9-037d-4f08-a26d-783cff0e2369` | Home tenant where the registration lives |
444-
| `cddc1229-ac2a-4b97-b78a-0e5cacb5865c` | `MngEnvMCAP675646.onmicrosoft.com` (workshop sandbox) |
445-
| `a34c69c7-8959-474a-9690-e98bfb0b55c6` | `devopsabcs.com` |
462+
| `aa93b9d9-037d-4f08-a26d-783cff0e2369` | Home tenant. Also hosts the workshop Azure subscription `ME-MngEnvMCAP675646-emknafo-1`. |
463+
| `a34c69c7-8959-474a-9690-e98bfb0b55c6` | `devopsabcs.com`. SP id in target tenant: `8f504d8c-0130-485d-813f-771742ce1ade`. |
446464
447465
Pick the option below that matches the level of access available in the target tenant.
448466
467+
#### Prerequisite — register at least one SPA redirect URI on the app object
468+
469+
This is a one-time setup against the home-tenant app object. Without it, every consent attempt ends with `AADSTS500113: No reply address is registered for the application`. Run from a session signed into the home tenant.
470+
471+
PowerShell mangles inline single-quoted multiline JSON before it reaches `az rest`, so write the body to a file first:
472+
473+
```powershell
474+
$objectId = "25027aaf-05ce-4257-a574-405a50d91a46"
475+
476+
@{
477+
spa = @{
478+
redirectUris = @(
479+
"http://localhost:4200",
480+
"https://app-evidence-spa-workshop.azurewebsites.net"
481+
)
482+
}
483+
} | ConvertTo-Json -Depth 5 | Set-Content -Path .\spa-redirects.json -Encoding utf8
484+
485+
az rest --method PATCH `
486+
--uri "https://graph.microsoft.com/v1.0/applications/$objectId" `
487+
--headers "Content-Type=application/json" `
488+
--body "@spa-redirects.json"
489+
490+
# Verify (HTTP 204 returns no body on success; this GET confirms the change)
491+
az rest --method GET `
492+
--uri "https://graph.microsoft.com/v1.0/applications/$objectId" `
493+
--query "spa" -o json
494+
495+
Remove-Item .\spa-redirects.json
496+
```
497+
449498
#### Option A — Admin consent URL (recommended)
450499

451500
A Global Administrator (or Privileged Role / Cloud Application Administrator) of the target tenant opens the link below **while signed into that tenant**. It instantiates the service principal and grants tenant-wide consent for any *Admin consent required* delegated scopes.
452501

453502
```text
454-
https://login.microsoftonline.com/{tenantId}/adminconsent?client_id=0713f130-110b-4982-9ce3-8c9227935ca0&redirect_uri=https://app-evidence-spa-workshop.azurewebsites.net
503+
https://login.microsoftonline.com/{tenantId}/adminconsent?client_id=0713f130-110b-4982-9ce3-8e9227935ca0
455504
```
456505

457506
For `devopsabcs.com` specifically:
458507

459508
```text
460-
https://login.microsoftonline.com/a34c69c7-8959-474a-9690-e98bfb0b55c6/adminconsent?client_id=0713f130-110b-4982-9ce3-8c9227935ca0&redirect_uri=https://app-evidence-spa-workshop.azurewebsites.net
509+
https://login.microsoftonline.com/a34c69c7-8959-474a-9690-e98bfb0b55c6/adminconsent?client_id=0713f130-110b-4982-9ce3-8e9227935ca0
461510
```
462511

463-
The `redirect_uri` must match a SPA redirect URI registered on the app. Drop the parameter to land on the generic Entra consent confirmation page instead.
512+
Use the **`/adminconsent`** path, not `/v2.0/adminconsent`. The v2 endpoint requires a `scope` parameter and fails with `AADSTS900144: The request body must contain the following parameter: 'scope'` if it's omitted. The non-v2 endpoint consents to every permission declared in the app manifest with no extra parameters.
513+
514+
Optionally append `&redirect_uri=https://app-evidence-spa-workshop.azurewebsites.net` to land on the deployed SPA after consent. The `redirect_uri` must exactly match a SPA redirect URI registered on the app (see the prerequisite above). Drop the parameter to land on the generic Entra consent confirmation page instead.
464515

465516
#### Option B — User sign-in
466517

@@ -472,7 +523,7 @@ Run from a session signed in as a Global Administrator of the target tenant. The
472523

473524
```powershell
474525
Connect-MgGraph -TenantId <targetTenantId> -Scopes "Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All"
475-
New-MgServicePrincipal -AppId 0713f130-110b-4982-9ce3-8c9227935ca0
526+
New-MgServicePrincipal -AppId 0713f130-110b-4982-9ce3-8e9227935ca0
476527
```
477528

478529
The service principal then appears under *Enterprise applications* in the target tenant. Tenant-wide consent can be granted from *Permissions → Grant admin consent*.
@@ -484,23 +535,34 @@ If the Microsoft Graph PowerShell module is not installed, use `az` instead —
484535
```powershell
485536
# 1. Confirm the registration is actually multi-tenant (run in the home tenant).
486537
az login --tenant aa93b9d9-037d-4f08-a26d-783cff0e2369 --allow-no-subscriptions
487-
az ad app show --id 0713f130-110b-4982-9ce3-8c9227935ca0 `
538+
az ad app show --id 0713f130-110b-4982-9ce3-8e9227935ca0 `
488539
--query "{name:displayName, appId:appId, signInAudience:signInAudience}" -o table
489540
# Expected: signInAudience = AzureADMultipleOrgs. If it shows AzureADMyOrg, fix it:
490-
az ad app update --id 0713f130-110b-4982-9ce3-8c9227935ca0 --sign-in-audience AzureADMultipleOrgs
541+
az ad app update --id 0713f130-110b-4982-9ce3-8e9227935ca0 --sign-in-audience AzureADMultipleOrgs
491542
492543
# 2. Switch to the target tenant and check whether the service principal already exists.
493544
az login --tenant <targetTenantId> --allow-no-subscriptions
494-
az ad sp list --filter "appId eq '0713f130-110b-4982-9ce3-8c9227935ca0'" `
545+
az ad sp list --filter "appId eq '0713f130-110b-4982-9ce3-8e9227935ca0'" `
495546
--query "[].{name:displayName, appId:appId, id:id}" -o table
496547
497548
# 3. If the previous command returned nothing, install the service principal.
498-
az ad sp create --id 0713f130-110b-4982-9ce3-8c9227935ca0
549+
az ad sp create --id 0713f130-110b-4982-9ce3-8e9227935ca0
499550
```
500551

501-
Common gotcha: signing in to the wrong tenant in step 1 returns `Resource '...' does not exist` from `az ad app show`. The home tenant for this registration is `aa93b9d9-037d-4f08-a26d-783cff0e2369`; do not confuse it with the workshop sandbox tenant `cddc1229-...`.
552+
Common gotchas:
553+
554+
- `az ad app show` returning `Resource '...' does not exist` can mean either (a) you signed in to the wrong tenant, or (b) you signed in to the right tenant but your account has no directory role there — Microsoft Graph returns 404 for both "missing" and "forbidden". Subscription Owner is **not** a directory role; you also need *Cloud Application Administrator* (or higher) in the home tenant to read the registration.
555+
- Step 3 (`az ad sp create`) requires *Cloud Application Administrator*, *Application Administrator*, or *Global Administrator* in the **target** tenant. A guest account without a directory role gets `Insufficient privileges to complete the operation` — in that case fall back to Option A and have a target-tenant admin click the consent URL.
556+
- If `az ad sp create` fails with *"does not reference a valid application object"*, the AppId is almost certainly mistyped. Re-verify it by reading the manifest from the home tenant (see the *Identifiers* table above) before assuming a more exotic cause like the *Allow only certain tenants (Preview)* gate.
502557

503-
If `az ad sp create` fails with *"does not reference a valid application object"* even though `signInAudience` is `AzureADMultipleOrgs`, the *Allow only certain tenants (Preview)* gate on the registration is blocking provisioning. Workaround: in the home tenant portal, flip the registration to *Allow all tenants* temporarily, run `az ad sp create` from the target tenant, then flip the gate back to *Allow only certain tenants* with the target tenant listed — the service principal persists.
558+
### Common AADSTS errors hit during onboarding
559+
560+
| Code | Symptom | Root cause |
561+
| --- | --- | --- |
562+
| `AADSTS700016` | `Application with identifier '<appId>' was not found in the directory '<tenant>'` | (1) AppId typo — `c`/`e` confusion in the portal URL is the most common cause. Verify by reading the app manifest by *object id*, not by AppId. (2) Service principal not yet provisioned in the target tenant — run `az ad sp create --id <appId>` from a target-tenant admin session. |
563+
| `AADSTS900144` | `The request body must contain the following parameter: 'scope'` | You hit `/{tenantId}/v2.0/adminconsent` instead of `/{tenantId}/adminconsent`. Drop the `/v2.0/` segment. |
564+
| `AADSTS500113` | `No reply address is registered for the application` | The home-tenant app object has no SPA redirect URIs. Add at least one via the prerequisite Graph PATCH above. |
565+
| `AADSTS50020` | User from tenant T cannot sign in to a tenant-pinned authority | SPA `auth-config.ts` is using `https://login.microsoftonline.com/{homeTenantId}` instead of `/common` or `/organizations`. See the code-changes section below. |
504566

505567
### After consent
506568

@@ -509,13 +571,16 @@ Once the service principal exists in the foreign tenant, an admin in that tenant
509571
- Toggle *Assignment required?* under *Properties* if user/group gating is desired.
510572
- Assign users or groups to the `CaseReader` and `CaseAdmin` app roles under *Users and groups*. App roles are defined on the home-tenant registration but assigned in each foreign tenant's enterprise application.
511573

574+
The consent screen currently lists only **"Sign in and read user profile"**. That's because the multi-tenant registration's `requiredResourceAccess` only declares the default `User.Read` scope — the API's `Evidence.Read` scope has not yet been added. Adding it would surface a second permission line and grant API access at the same admin-consent click.
575+
512576
### What still needs to change in code to fully support multi-tenant
513577

514-
The registration is multi-tenant, but the SPA and API are not yet configured for it. The following changes are required before tokens issued by `devopsabcs.com` (or any other allowed tenant) will be accepted end to end:
578+
The registration is multi-tenant and consent works end to end, but the SPA and API are not yet configured to accept tokens from foreign tenants. The following changes are required before tokens issued by `devopsabcs.com` (or any other consented tenant) will be honoured by the application itself:
515579

516580
- **SPA `auth-config.ts`**: change the `authority` from a tenant-specific `https://login.microsoftonline.com/{homeTenantId}` to `https://login.microsoftonline.com/common` (any account) or `https://login.microsoftonline.com/organizations` (work/school accounts only). A tenant-pinned authority will fail with `AADSTS50020` for users from other tenants.
517-
- **API JWT validation** ([SecurityConfig.java](sample-app/api/src/main/java/com/example/evidence/config/SecurityConfig.java)): the issuer is currently a single tenant URL. For multi-tenant, validate against the `https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration` discovery document (which exposes the multi-tenant signing keys) and **enforce the allow-list yourself** by reading the `tid` claim and comparing it to the same set of tenant ids configured in Entra. Without that check, any tenant on the public internet that has consented could mint valid tokens.
581+
- **API JWT validation** ([SecurityConfig.java](sample-app/api/src/main/java/com/example/evidence/config/SecurityConfig.java)): the issuer is currently a single tenant URL. For multi-tenant, validate against the `https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration` discovery document (which exposes the multi-tenant signing keys) and **enforce the allow-list yourself** by reading the `tid` claim and comparing it to a configured set of allowed tenant ids. Without that check, any tenant on the public internet that has consented could mint valid tokens.
518582
- **API app registration**: the resource server (`Evidence API`) must also be set to *Multiple Entra ID tenants* in *Authentication → Supported accounts*, otherwise tokens with `aud=api://{apiClientId}` will not be issued for users outside the home tenant.
583+
- **Multi-tenant SPA `requiredResourceAccess`**: add the API's `Evidence.Read` delegated scope so admin consent in foreign tenants covers it in one click instead of requiring a second consent flow against the API.
519584

520585
Until those three changes ship, the multi-tenant registration is a placeholder — it can be onboarded into other tenants for a smoke test, but `acquireTokenSilent` and the API JWT validator will reject the resulting tokens.
521586

app.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications/$entity",
3+
"addIns": [],
4+
"api": {
5+
"acceptMappedClaims": null,
6+
"knownClientApplications": [],
7+
"oauth2PermissionScopes": [],
8+
"preAuthorizedApplications": [],
9+
"requestedAccessTokenVersion": null
10+
},
11+
"appId": "0713f130-110b-4982-9ce3-8e9227935ca0",
12+
"appRoles": [],
13+
"applicationTemplateId": null,
14+
"certification": null,
15+
"createdByAppId": "18ed3507-a475-4ccb-b669-d66bc9f2a36e",
16+
"createdDateTime": "2026-05-01T14:54:21Z",
17+
"defaultRedirectUri": null,
18+
"deletedDateTime": null,
19+
"description": null,
20+
"disabledByMicrosoftStatus": null,
21+
"displayName": "Evidence Portal Multi-Tenant SPA",
22+
"groupMembershipClaims": null,
23+
"id": "25027aaf-05ce-4257-a574-405a50d91a46",
24+
"identifierUris": [],
25+
"info": {
26+
"logoUrl": null,
27+
"marketingUrl": null,
28+
"privacyStatementUrl": null,
29+
"supportUrl": null,
30+
"termsOfServiceUrl": null
31+
},
32+
"isDeviceOnlyAuthSupported": null,
33+
"isDisabled": null,
34+
"isFallbackPublicClient": null,
35+
"keyCredentials": [],
36+
"nativeAuthenticationApisEnabled": null,
37+
"notes": null,
38+
"optionalClaims": null,
39+
"parentalControlSettings": {
40+
"countriesBlockedForMinors": [],
41+
"legalAgeGroupRule": "Allow"
42+
},
43+
"passwordCredentials": [],
44+
"publicClient": {
45+
"redirectUris": []
46+
},
47+
"publisherDomain": "MngEnvMCAP675646.onmicrosoft.com",
48+
"requestSignatureVerification": null,
49+
"requiredResourceAccess": [
50+
{
51+
"resourceAccess": [
52+
{
53+
"id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d",
54+
"type": "Scope"
55+
}
56+
],
57+
"resourceAppId": "00000003-0000-0000-c000-000000000000"
58+
}
59+
],
60+
"samlMetadataUrl": null,
61+
"serviceManagementReference": null,
62+
"servicePrincipalLockConfiguration": {
63+
"allProperties": true,
64+
"credentialsWithUsageSign": true,
65+
"credentialsWithUsageVerify": true,
66+
"identifierUris": false,
67+
"isEnabled": true,
68+
"tokenEncryptionKeyId": true
69+
},
70+
"signInAudience": "AzureADMultipleOrgs",
71+
"spa": {
72+
"redirectUris": []
73+
},
74+
"tags": [],
75+
"tokenEncryptionKeyId": null,
76+
"uniqueName": null,
77+
"verifiedPublisher": {
78+
"addedDateTime": null,
79+
"displayName": null,
80+
"verifiedPublisherId": null
81+
},
82+
"web": {
83+
"homePageUrl": null,
84+
"implicitGrantSettings": {
85+
"enableAccessTokenIssuance": false,
86+
"enableIdTokenIssuance": false
87+
},
88+
"logoutUrl": null,
89+
"redirectUriSettings": [],
90+
"redirectUris": []
91+
}
92+
}

0 commit comments

Comments
 (0)