mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7869619510 |
@@ -0,0 +1,15 @@
|
|||||||
|
*.cmd text eol=lf
|
||||||
|
*.cs text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.ps1 text eol=lf
|
||||||
|
*.psd1 text eol=lf
|
||||||
|
*.psm1 text eol=lf
|
||||||
|
*.xaml text eol=lf
|
||||||
|
# Avalonia markup was missing here while *.xaml was covered, so an editor that
|
||||||
|
# wrote CRLF turned a one-row change into a whole-file diff. All 96 committed
|
||||||
|
# .axaml files are already LF, so this normalizes nothing retroactively.
|
||||||
|
*.axaml text eol=lf
|
||||||
|
# Shell launchers must stay LF or they will not run on macOS/Linux
|
||||||
|
*.command text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
title: ""
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Ideas is for shaping something before it becomes a request. Say what you
|
||||||
|
are trying to achieve rather than the control you picture, and it will be
|
||||||
|
clear whether the application should grow a feature or already has one.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Which version are you using?
|
||||||
|
options:
|
||||||
|
- 4.0 beta
|
||||||
|
- 3.x
|
||||||
|
- Neither yet, just looking
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: goal
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to achieve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: How do you picture it working?
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: scale
|
||||||
|
attributes:
|
||||||
|
label: How often, and at what scale?
|
||||||
|
description: >
|
||||||
|
How many tenants, how many policies, how often you do it. Scale changes
|
||||||
|
the answer more than anything else here.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
title: "[Question] "
|
||||||
|
labels: ["needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Most questions here turn out to be one of four things: a sign-in method
|
||||||
|
the embedded window cannot complete, a list that looks short because the
|
||||||
|
version you are on stops paging, a permission the app registration does
|
||||||
|
not hold, or an object type that has no public Graph endpoint. The fields
|
||||||
|
below let that be spotted straight away.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
options:
|
||||||
|
- 4.0 beta
|
||||||
|
- 3.x
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: signin
|
||||||
|
attributes:
|
||||||
|
label: How do you sign in?
|
||||||
|
options:
|
||||||
|
- Interactive, embedded window (the default)
|
||||||
|
- Interactive, Web Account Manager (WAM)
|
||||||
|
- Interactive, system browser
|
||||||
|
- Device code
|
||||||
|
- Application and secret
|
||||||
|
- Application and certificate
|
||||||
|
- Managed identity or federated credential
|
||||||
|
- Bring your own token
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: cloud
|
||||||
|
attributes:
|
||||||
|
label: Cloud
|
||||||
|
options:
|
||||||
|
- Public (commercial)
|
||||||
|
- US Government (GCC High)
|
||||||
|
- US Government (DoD)
|
||||||
|
- China (21Vianet)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to do?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: tried
|
||||||
|
attributes:
|
||||||
|
label: What have you tried, and what happened?
|
||||||
|
description: >
|
||||||
|
Paste any error text here. **Remove tenant identifiers, user names and
|
||||||
|
tokens first.**
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# GitHub renders this as the "Sponsor" button on the repository page
|
||||||
|
# (public repositories, default branch). Buy Me a Coffee takes the
|
||||||
|
# username, not the URL: https://buymeacoffee.com/MickeK
|
||||||
|
buy_me_a_coffee: MickeK
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
name: Bug report (version 3.x)
|
||||||
|
description: Something is wrong in the current release. Windows only.
|
||||||
|
title: "[3.x] "
|
||||||
|
labels: ["bug", "v3", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Version 3 stays supported until 4.0 leaves beta.
|
||||||
|
|
||||||
|
Before filing, check whether 4.0 already fixes it. Several long-standing
|
||||||
|
reports here are addressed there: sign-in with passkeys and other
|
||||||
|
phishing-resistant methods, lists that stopped at 20, 100 or a few
|
||||||
|
hundred objects, sovereign cloud sign-in, and running without a user
|
||||||
|
present. The 4.0 beta lives on the `v4` branch.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: preflight
|
||||||
|
attributes:
|
||||||
|
label: Before reporting
|
||||||
|
options:
|
||||||
|
- label: >
|
||||||
|
If my sign-in involves a passkey, security key, Windows Hello or a
|
||||||
|
phishing-resistant policy: I know the embedded sign-in window cannot
|
||||||
|
complete those, and I have said so below rather than reporting it as
|
||||||
|
a broken login.
|
||||||
|
required: true
|
||||||
|
- label: >
|
||||||
|
I searched existing issues and discussions, including closed ones.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
placeholder: 3.10.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: signin
|
||||||
|
attributes:
|
||||||
|
label: How did you sign in?
|
||||||
|
options:
|
||||||
|
- Interactive, embedded window (the default)
|
||||||
|
- Interactive, other
|
||||||
|
- Application and secret
|
||||||
|
- Application and certificate
|
||||||
|
- Not signed in / sign-in is the problem
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: cloud
|
||||||
|
attributes:
|
||||||
|
label: Cloud
|
||||||
|
options:
|
||||||
|
- Public (commercial)
|
||||||
|
- US Government (GCC High)
|
||||||
|
- US Government (DoD)
|
||||||
|
- China (21Vianet)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: powershell
|
||||||
|
attributes:
|
||||||
|
label: PowerShell version
|
||||||
|
description: Run `$PSVersionTable.PSVersion`.
|
||||||
|
placeholder: "5.1.22621.4391"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Which part of the application?
|
||||||
|
options:
|
||||||
|
- Sign-in and authentication
|
||||||
|
- Export
|
||||||
|
- Import
|
||||||
|
- Copy
|
||||||
|
- Compare
|
||||||
|
- Documentation output
|
||||||
|
- Assignments, groups or filters
|
||||||
|
- Bulk or silent operations
|
||||||
|
- ADMX or tools
|
||||||
|
- The user interface itself
|
||||||
|
- Something else
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened, and what did you expect instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: >
|
||||||
|
The relevant lines, or the error text. **Remove tenant identifiers, user
|
||||||
|
names, access tokens and secrets before pasting.**
|
||||||
|
render: text
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
name: Bug report (version 4.0 beta)
|
||||||
|
description: Something is wrong in 4.0. Runs on Windows, macOS and Linux.
|
||||||
|
title: "[4.0] "
|
||||||
|
labels: ["bug", "v4", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for testing the beta. Four fields below do most of the work:
|
||||||
|
**how you signed in**, **which cloud**, **which operating system** and
|
||||||
|
**the log**. Reports without them usually need a round trip before
|
||||||
|
anything can happen.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: preflight
|
||||||
|
attributes:
|
||||||
|
label: Before reporting
|
||||||
|
description: These three cover the majority of beta reports so far.
|
||||||
|
options:
|
||||||
|
- label: >
|
||||||
|
I read the release notes for this beta, including the breaking changes.
|
||||||
|
required: true
|
||||||
|
- label: >
|
||||||
|
If my sign-in involves a passkey, security key, Windows Hello or any
|
||||||
|
phishing-resistant policy: I enabled **Use Web Account Manager (WAM)
|
||||||
|
for login** or **Use system browser for login** in Settings, and tried
|
||||||
|
again. The embedded window cannot complete those methods.
|
||||||
|
required: true
|
||||||
|
- label: >
|
||||||
|
My problem is not Inventory Policies returning 403. That endpoint is
|
||||||
|
not published on the public Graph API, so the application cannot read
|
||||||
|
it with a normal sign-in. It is a Microsoft limitation, not a bug.
|
||||||
|
A bring-your-own-token sign-in can reach it.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: The About dialog, or the ModuleVersion in IntuneManagement.psd1.
|
||||||
|
placeholder: 4.0.0-beta1
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: signin
|
||||||
|
attributes:
|
||||||
|
label: How did you sign in?
|
||||||
|
description: >
|
||||||
|
The single most useful field in this form. Roughly a third of all reports
|
||||||
|
on this project have turned out to be sign-in behaviour rather than the
|
||||||
|
feature being reported.
|
||||||
|
options:
|
||||||
|
- Interactive, embedded window (the default)
|
||||||
|
- Interactive, Web Account Manager (WAM)
|
||||||
|
- Interactive, system browser
|
||||||
|
- Device code
|
||||||
|
- Application and secret
|
||||||
|
- Application and certificate
|
||||||
|
- Managed identity or federated credential
|
||||||
|
- Bring your own token
|
||||||
|
- Not signed in / sign-in is the problem
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: cloud
|
||||||
|
attributes:
|
||||||
|
label: Cloud
|
||||||
|
options:
|
||||||
|
- Public (commercial)
|
||||||
|
- US Government (GCC High)
|
||||||
|
- US Government (DoD)
|
||||||
|
- China (21Vianet)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
description: 4.0 runs the full application outside Windows, so this now matters.
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- macOS (Apple Silicon)
|
||||||
|
- macOS (Intel)
|
||||||
|
- Linux
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: powershell
|
||||||
|
attributes:
|
||||||
|
label: PowerShell edition and version
|
||||||
|
description: Run `$PSVersionTable.PSVersion` and `$PSVersionTable.PSEdition`.
|
||||||
|
placeholder: "7.4.6, Core"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Which part of the application?
|
||||||
|
options:
|
||||||
|
- Sign-in and authentication
|
||||||
|
- Export
|
||||||
|
- Import
|
||||||
|
- Copy
|
||||||
|
- Compare
|
||||||
|
- Documentation output
|
||||||
|
- Assignments, groups or filters
|
||||||
|
- Bulk operations
|
||||||
|
- ADMX or tools
|
||||||
|
- The user interface itself
|
||||||
|
- Automation through the PowerShell commands
|
||||||
|
- Something else
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: objecttype
|
||||||
|
attributes:
|
||||||
|
label: Which object type, if it is specific to one
|
||||||
|
placeholder: Settings Catalog, Conditional Access, Win32 app, ...
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened, and what did you expect instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
placeholder: |
|
||||||
|
1. Sign in to ...
|
||||||
|
2. Open ...
|
||||||
|
3. Click ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: >
|
||||||
|
The relevant lines from the log file, or the error text. **Remove tenant
|
||||||
|
identifiers, user names, access tokens and secrets before pasting.** If
|
||||||
|
an exported policy file is needed, redact it or use one from a lab tenant.
|
||||||
|
render: text
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Turns off the "open a blank issue" escape hatch, so every report arrives
|
||||||
|
# through a form with the fields that make it answerable. Questions go to
|
||||||
|
# Discussions, which is where most of them already end up.
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Question, or not sure it is a bug
|
||||||
|
url: https://github.com/Micke-K/IntuneManagement/discussions/categories/q-a
|
||||||
|
about: Ask in Q&A. Most "it does not show my policies" reports are answered there in a day.
|
||||||
|
- name: Feature idea worth discussing first
|
||||||
|
url: https://github.com/Micke-K/IntuneManagement/discussions/categories/ideas
|
||||||
|
about: Ideas that are still taking shape belong here. A concrete request can go straight to an issue.
|
||||||
|
- name: Version 4.0 beta feedback
|
||||||
|
url: https://github.com/Micke-K/IntuneManagement/discussions/categories/announcements
|
||||||
|
about: Testing the 4.0 beta? Start at the pinned announcement, which lists what to try first.
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/Micke-K/IntuneManagement/security/advisories/new
|
||||||
|
about: Never report a security problem in a public issue. Use private reporting.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Something the application should do and does not.
|
||||||
|
title: "[Request] "
|
||||||
|
labels: ["enhancement", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
If the idea is still taking shape, [Ideas in
|
||||||
|
Discussions](https://github.com/Micke-K/IntuneManagement/discussions/categories/ideas)
|
||||||
|
is the better room. Use this form when you can describe the outcome you
|
||||||
|
want.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Which version is this for?
|
||||||
|
description: >
|
||||||
|
This one is read by a human, not by automation, so say what you mean.
|
||||||
|
A request that 4.0 already covers is worth checking against the release
|
||||||
|
notes first.
|
||||||
|
options:
|
||||||
|
- Version 4.0
|
||||||
|
- Version 3.x
|
||||||
|
- Either
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to do?
|
||||||
|
description: >
|
||||||
|
The situation, not the solution. "I move policies between two tenants
|
||||||
|
every month and have to redo the assignments by hand" tells more than
|
||||||
|
"add a button".
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: What would you like it to do?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: workaround
|
||||||
|
attributes:
|
||||||
|
label: How do you handle it today?
|
||||||
|
description: Including "not at all", which is useful to know.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Which part of the application?
|
||||||
|
options:
|
||||||
|
- Export
|
||||||
|
- Import
|
||||||
|
- Copy
|
||||||
|
- Compare
|
||||||
|
- Documentation output
|
||||||
|
- Assignments, groups or filters
|
||||||
|
- Bulk operations
|
||||||
|
- ADMX or tools
|
||||||
|
- The user interface
|
||||||
|
- Automation through the PowerShell commands
|
||||||
|
- A policy type that is not supported yet
|
||||||
|
- Something else
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
+50
@@ -3,5 +3,55 @@
|
|||||||
.git/
|
.git/
|
||||||
/*.Log
|
/*.Log
|
||||||
/*.Lo_
|
/*.Lo_
|
||||||
|
/*.log
|
||||||
|
/*.lo_
|
||||||
/*.csv
|
/*.csv
|
||||||
|
/*.zip
|
||||||
|
/*.new
|
||||||
|
/*.old
|
||||||
|
/*.zip
|
||||||
|
/TestResults
|
||||||
|
/TestResults.xml
|
||||||
|
/TestResults*.xml
|
||||||
|
Start-BYODToken.ps1
|
||||||
|
/UI/Avalonia/Bootstrap/bin/
|
||||||
|
# Main-thread hook compile output (the one ~10 KB DLL it produces IS tracked,
|
||||||
|
# under Bin/MainThreadHook; see UI/Avalonia/Bootstrap/Publish-MainThreadHook.ps1).
|
||||||
|
/UI/Avalonia/Bootstrap/MainThreadHook/bin/
|
||||||
|
/UI/Avalonia/Bootstrap/obj/
|
||||||
|
IntuneManagement.log
|
||||||
|
|
||||||
|
# .NET build output / intermediates (the Avalonia payload is published into
|
||||||
|
# Bin/Avalonia; these are the transient compile dirs and restore artifacts).
|
||||||
|
# Note: only ignore the lowercase build dirs, never the tracked Bin/ runtime
|
||||||
|
# folder — on case-insensitive filesystems a bare "bin/" would match it.
|
||||||
|
**/obj/
|
||||||
|
*.user
|
||||||
|
project.assets.json
|
||||||
|
*.nupkg
|
||||||
|
|
||||||
|
# Timestamped backup folders (e.g. Bin/MSAL_PS7.bak-20260517-181539) and other
|
||||||
|
# scratch/temp artifacts.
|
||||||
|
*.bak-*/
|
||||||
|
*.bak/
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Stray literal-path artifact created on non-Windows by older code that didn't
|
||||||
|
# expand %LOCALAPPDATA% (kept ignored so it can never be re-committed).
|
||||||
|
%LOCALAPPDATA%/
|
||||||
|
|
||||||
|
# Test run logs
|
||||||
|
/Tests/Logs/
|
||||||
|
|
||||||
|
.claude/settings.local.json
|
||||||
|
CLAUDE.local.md
|
||||||
|
Internal/Documentation/IntuneManagement.log
|
||||||
|
|
||||||
|
# Residue if the OLD-project automation batch ever runs with an unreplaced
|
||||||
|
# txtJsonFileName placeholder (see Tests/Setup/Invoke-AutomationOrchestrator.ps1)
|
||||||
|
REPLACED_AT_RUNTIME
|
||||||
|
|
||||||
|
# Local-only online-test assets (binaries: .intunewin, vendor .admx/.adml).
|
||||||
|
# Not committed - license/size. Tests skip when absent. See Docs/Testing.md.
|
||||||
|
Tests/Fixtures/Automation.Local/
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 523 KiB |
-214
@@ -1,214 +0,0 @@
|
|||||||
# ADMX Ingestion
|
|
||||||
|
|
||||||
The script can create Custom Profiles based on ADMX ingestion. ADMX ingestion is a way to support existing ADMX files in an MDM environment. Windows uses the [Policy CSP](https://docs.microsoft.com/en-us/windows/client-management/mdm/policy-configuration-service-provider) to apply the configuration.
|
|
||||||
|
|
||||||
Microsoft links:
|
|
||||||
|
|
||||||
* ADMX Ingestion documentation can be found [here](https://docs.microsoft.com/en-us/windows/client-management/mdm/understanding-admx-backed-policies) and [here](https://docs.microsoft.com/en-us/windows/client-management/mdm/enable-admx-backed-policies-in-mdm)
|
|
||||||
* Additional information including blocked registry keys can be found [here](https://docs.microsoft.com/en-us/windows/client-management/mdm/win32-and-centennial-app-policy-configuration)
|
|
||||||
* Shema definition documentation can be found [here](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-gpreg/6e10478a-e9e6-4fdc-a1f6-bdd9bd7f2209)
|
|
||||||
* Old ADMX schema documentation (Vista) including attribute information can be found [here](http://download.microsoft.com/download/5/0/8/5081217f-4a2a-470e-a7fa-5976e40b0839/Group%20Policy%20ADMX%20Syntax%20Reference%20Guide.doc)
|
|
||||||
* Another schema documentation including attribute information can be found [here](https://docs.microsoft.com/en-us/previous-versions/windows/desktop/policy/admx-schema)
|
|
||||||
|
|
||||||
## ADMX Import
|
|
||||||
|
|
||||||
The **ADMX Import** tool is used for configuring 3rd party applications e.g. Chrome, Google Update etc., These ADMX files are available from the software vendor. An ADMX can be loaded in the tool and all settings can be configured using a similar UI as GPMC. When the ADMX is loaded, the script will look for an ADML file that is either in the same directory or in the en-US subdirectory. An ADML file can also be loaded manually, if another language should be used in the UI.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The image above shows the the tool after the chrome.admx file was loaded. The tool supports delivering ADMX settings to HKLM (Computer Settings) and HKCU (User Settings). Categories will be added based on the Class attribute for each ADMX policy setting. The *All settings* category will display all the settings for the base category (Computer or User)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
A policy setting can either be edited via double-clicking an item or right-clicking and select Edit.
|
|
||||||
|
|
||||||
The *Intune OMA-URI name* property specifies the name of the OMA-URI row in the Custom Profile. This is optional and if it is not specified, the script will use the name of the policy.
|
|
||||||
|
|
||||||
A policy must be set to Enabled before any changes can be made. The *Policy* tab will list all possible settings for the policy. This could be a dropdown box, text box, check box, numeric up-down box etc. The script creates the controls based on the presentation settings in the ADML file. An ADML is not mandatory but the controls and the UI could cause unpredictive results. Always use the associated ADML for correctly generated controls.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The *OMA-URI Settings* tab contains the configured settings. This is the string that will be added to the enabled policy. This can be manually configure in case there is something that is not supported by the script. Do **NOT** add <enabled /> or <disabled /> to this text box. The script will add that automatically. If *Manual configuration* is checked, the script will upload the text as it is specified, including additional manual changes. If it is not checked, the script will generate the text when importing the profile. If manual configuration is added and then checkbox is cleared, those changes will be lost during the upload.
|
|
||||||
|
|
||||||
The *XML Definition* tab contains the XML node for the ADMX policy. This is used for reference in case manual configuration is required.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The *Import* tab is used for creating the Custom Profile in Intune. The *Custom Profile Name* is mandatory and specifies the name of the profile.
|
|
||||||
|
|
||||||
The *ADMX Policy File Name* is mandatory and this should be a globally unique name. This will generate an ADMX file on the client. See the Deep Dive section for more information.
|
|
||||||
|
|
||||||
The *ADMX App Id* is mandatory and this does not have to be unique but it is recommended in some circumstances e.g. if multiple versions of the Chrome ADMX file is uploaded, it is recommended to add the version to the *ADMX Policy File Name* and *ADMX App Id* e.g. Chrome91. See the Deep Dive section for more information.
|
|
||||||
|
|
||||||
The *Ingest ADMX file* is checked by default and this will included the ADMX file ingestion in the Custom Profile. If there will be multiple Custom Profiles based on the same ADMX file, it might be better to have one Custom Profile for the ADMX ingestion and one separate Custom Profile for each of the settings. This requires that the same *ADMX App Id* is used for each Custom Profile that is based in the ingested ADMX file.
|
|
||||||
|
|
||||||
The *OMA-URI Name for the ADMX ingestion* specifies the name of the OMA-URI row inside the Custom Profile. This is optional. It will be set to a value based on the loaded file name by default e.g. chrome.admx Ingestion.
|
|
||||||
|
|
||||||
The *Import* button will create the Custom Profile in Intune. There is no visual information if the profile was created successfully but the log will display the name and id of the created profile. A message box will be displayed if it fails to create the Custom Profile.
|
|
||||||
|
|
||||||
## Reg Values
|
|
||||||
|
|
||||||
The **Reg Values** tool can be used to create registry values in HKLM and HKCU. This uses the same functionality as the ADMX Import tool; ADMX ingestion. The difference is that the Reg Values tool builds the ADMX file in the background based on the added registry values. There are some benefits of using this over a PowerShell script e.g. Intune will state if the registry keys were applied successfully and if a conflict or an error occurred.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The initial screen include the options of specifying the Custom Profile name and description. The *Policy type* can either be Policy or Preferences. See Known Issues and Limitations for more information about policy types.
|
|
||||||
|
|
||||||
The *Added reg values* list contains the ADMX policies. Each ADMX policy can contain one or more registry values.
|
|
||||||
|
|
||||||
The top part of the *Add new reg policy* form specifies the attributes on the policy node in the ADMX file. The *Policy name* identifies the policy. This cannot contain any spaces. The *Policy status* property specifies if the policy should be enabled or disabled. The hive and the key properties specifies where the registry values should be added. The *Reg key* property is a global value for all added registry values in the bottom section.
|
|
||||||
|
|
||||||
The *Policy value* is an optional value. This should only be used if the registry policy should add a value that specifies if it is enabled/disable e.g. 1 or 0. This will use the enabledValue and disabledValue nodes in the background. If this value is specified, a registry value (DWORD) will be set to 1 when the policy is enabled or 0 if the policy is disabled.
|
|
||||||
|
|
||||||
The lower part of the form specifies individual registry values. The tool support creating/setting the following type of registry values:
|
|
||||||
|
|
||||||
* String
|
|
||||||
* Expanded string (String with Expanded checked)
|
|
||||||
* Multi-string
|
|
||||||
* DWORD
|
|
||||||
* List - a key/value string list. Each key will be a string value.
|
|
||||||
|
|
||||||
The *Key* property is not required unless the value is located in a different location than specified in the *Reg key* property. This is used when specifying values for the List type. The List type values will then be added to a separate key.
|
|
||||||
|
|
||||||
**Note:** The List type does not support specifying the value name since all values are creating in a separate key.
|
|
||||||
|
|
||||||
*Value name* and *Value* properties specifies the values that should be created in the registry.
|
|
||||||
|
|
||||||
Modify existing values by double-clicking on the value in the *Added reg values* list.
|
|
||||||
|
|
||||||
The *Additional value settings* section has additional settings for a policy type. This can be used to create a REG_EXPAND_SZ instead of a REG_SZ etc.
|
|
||||||
|
|
||||||
**Note:** The *Do not overwrite value* will set the soft attribute. This does **NOT** work, at least not outside the Software\Policies area on a cloud only joined device. This property is kept until more testing can confirm that it doesn't work at all.
|
|
||||||
|
|
||||||
Example of setting registry values for a device and a user:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of adding HKCU settings
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of adding HKLM settings
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of generated ADMX file
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of the created Custom Profile created.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of the OMA-URI row for ADMX ingestion for a custom registry value.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of the OMA-URI row for specifying the registry values to set.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of the implemented HKCU settings for a user
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of the implemented HKLM settings for a device
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Example of the List type implementation on a device
|
|
||||||
|
|
||||||
## Deep Dive
|
|
||||||
|
|
||||||
The [Policy CSP](https://docs.microsoft.com/en-us/windows/client-management/mdm/policy-configuration-service-provider) is used when ingesting ADMX files. When a Custom Profile with ADMX ingestion is assigned to a device, the Policy CSP will add information about the ADMX file in the registry and create an ADMX file in the file system.
|
|
||||||
|
|
||||||
**ADMX ingestion:**
|
|
||||||
|
|
||||||
An ADMX is ingested by using the following OMA-URI path:
|
|
||||||
|
|
||||||
./device/Vendor/MSFT/Policy/ConfigOperations/ADMXInstall/<AppID>/[Policy|Preference]/<ADMXFileName>
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
./device/Vendor/MSFT/Policy/ConfigOperations/ADMXInstall/Chrome/Policy/ChromeAdmx
|
|
||||||
|
|
||||||
This will generate an ADMX file, ChromeAdmx.admx, in the following folder on the device:
|
|
||||||
|
|
||||||
%ProgramData%\Microsoft\PolicyManager\ADMXIngestion\\<ProviderGuid>\\<AppID>\\[Policy|Preference]
|
|
||||||
|
|
||||||
The Reg Values tool will always use IntuneManagementReg as AppId. Each uploaded reg policy will have a unique named ADMX file, RegPolicy_<GUID>.
|
|
||||||
|
|
||||||
The Policy CSP will create multiple registry values under HKLM\Software\Microsoft\PolicyManager
|
|
||||||
|
|
||||||
The ADMXDefault part will contain each Category, with full path, specified in the ADMX file. This is why the AppID must be unique when using multiple versions of the same admx file since each of these files will have the same category IDs e.g. each Chrome version should be named based on the version like Chromev91.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Note:** If the same ADMX filename is used, the first file saved will win. It looks like Intune will not overwrite an existing ADMX file. That is why a unique name must be specified when different versions of the ADMX file is used.
|
|
||||||
|
|
||||||
There is an additional registry key added for the ADMX ingestion. Each ingested file will generate a key under AdmxInstalled.
|
|
||||||
|
|
||||||
AdmxInstalled\<ProviderGuid>\\<AppID>\\[Policy|Preference]\\<ADMXFileName>
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
\SOFTWARE\Microsoft\PolicyManager\AdmxInstalled\D12FCE57-F71E-4D0D-93EE-35C5E6F8C0D9\Chrome\Policy\ChromeAdmx
|
|
||||||
|
|
||||||
This key contains information when it was added, status and how many policies it has.
|
|
||||||
|
|
||||||
**Policy Settings**
|
|
||||||
|
|
||||||
Each setting is added based on the following OMA-URI path:
|
|
||||||
|
|
||||||
./[User|Device]/vendor/msft/policy/config/<AppID>~[Policy|Preference]~<CategoryPath>/<PolicyName>
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
./Device/Vendor/MSFT/Policy/Config/Chrome~Policy~googlechrome~Startup/ShowHomeButton
|
|
||||||
|
|
||||||
The CategoryPath is the full path to the category where the setting is defined. Each categoryId is separated with a ~. This should match the registry value specified in the image above in the ADMXDefault key.
|
|
||||||
|
|
||||||
One registry key for each ADMX policy is created under the Provider path (PolicyManager\Provider\\<GUID>) . This includes the OME-URI settings configured in the Custom Profile.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Applied settings are added to the Current registry key, Current\Device or Current\\<SID> depending if it is for the device or the user. There is one key for each policy category.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Troubleshooting**
|
|
||||||
|
|
||||||
The Event Viewer can be used for troubleshooting any issues with ADMX ingestion. The events are added to the following log:
|
|
||||||
|
|
||||||
Application and Services log\Microsoft\Windows\DeviceManagement-Enterprise-Diagnostics-Provider\Admin
|
|
||||||
|
|
||||||
The following events can be used for troubleshooting:
|
|
||||||
|
|
||||||
* 819 - Information: The policy settings was successfully deleted
|
|
||||||
* 831 - Information: The policy settings was successfully added
|
|
||||||
* 866 - Information: Update policy (This is followed after a 872 or 873 event)
|
|
||||||
* 872 - Information: Start updating existing ADMX ingestion
|
|
||||||
* 873 - Information: Starting new ADMX ingestion
|
|
||||||
* 865 - Error Catastrophic Failure. This could be that the ADMX is invalid.
|
|
||||||
* 404 - Error: This is generated for different reason
|
|
||||||
* Generated after a 865 Catastrophic Failure error
|
|
||||||
* The system cannot find the file. The CSP cannot file the specified ADMX file. This could happen when ADMX and policies are in separate Custom Profiles and the wrong AppID was specified in the OMA+URI paths or if the policy settings are applied before the ADMX ingestion.
|
|
||||||
* 454 - Error: This is listed when the Custom Profile is removed and the CSP cannot delete registry values outside Software\Policies.
|
|
||||||
|
|
||||||
## Known Issues and Limitations
|
|
||||||
|
|
||||||
* The created ADMX ingestion profiles has only been tested on cloud only joined devices.
|
|
||||||
* According to [this](https://docs.microsoft.com/en-us/windows/client-management/mdm/win32-and-centennial-app-policy-configuration) link, policies will NOT be enforced unless the device is domain joined. So only Hybrid devices would support enforced values. This means that all settings on cloud only joined devices will be set as Preference values e.g. set once and never updated.
|
|
||||||
|
|
||||||
**ADMX Import:**
|
|
||||||
|
|
||||||
* Only categories and policy names specified in the loaded ADMX/ADML file will be translated. If the ADMX uses strings outside the loaded ADML file, it might be blank or using the string id.
|
|
||||||
|
|
||||||
**Reg Values**
|
|
||||||
|
|
||||||
* The Preferences type is supported by the tool but tests shows that there is no difference in functionality compared to the Policy type on cloud only joined devices.
|
|
||||||
* The script will block some registry keys. These keys are blocked by Microsoft and a PowerShell script is required to write to these values. See [this](https://docs.microsoft.com/en-us/windows/client-management/mdm/win32-and-centennial-app-policy-configuration) link for more information.
|
|
||||||
* Values outside Software\Policies will **NOT** be deleted when the policy is removed.
|
|
||||||
* The tool supports all ADMX attributes specified in the schema but it looks like some functionalities are not supported by Windows or the Policy CSP e.g. the *soft* attribute should be set to true to avoid overwriting an existing value but all values were overwritten during the tests, even if the soft attribute was set.
|
|
||||||
* QWORD is not supported. The ADMX schema definition includes longDecimal which would create QWORD values but this is not supported in the Policy CSP. It will generate a Catastrophic Failure event in the Event Log.
|
|
||||||
* No support for enabledList/disabledList. This might be added in the future since this could make it very easy to create mapped drives via ADMX ingestion.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,494 @@
|
|||||||
|
{
|
||||||
|
"runtimeTarget": {
|
||||||
|
"name": ".NETCoreApp,Version=v8.0/win-x64",
|
||||||
|
"signature": ""
|
||||||
|
},
|
||||||
|
"compilationOptions": {},
|
||||||
|
"targets": {
|
||||||
|
".NETCoreApp,Version=v8.0": {},
|
||||||
|
".NETCoreApp,Version=v8.0/win-x64": {
|
||||||
|
"AvaloniaPayload/1.0.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3",
|
||||||
|
"Avalonia.Controls.DataGrid": "11.2.3",
|
||||||
|
"Avalonia.Desktop": "11.2.3",
|
||||||
|
"Avalonia.Fonts.Inter": "11.2.3",
|
||||||
|
"Avalonia.Markup.Xaml.Loader": "11.2.3",
|
||||||
|
"Avalonia.Themes.Fluent": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"AvaloniaPayload.dll": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia.BuildServices": "0.0.29",
|
||||||
|
"Avalonia.Remote.Protocol": "11.2.3",
|
||||||
|
"MicroCom.Runtime": "0.11.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Base.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.Controls.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.DesignerSupport.dll": {
|
||||||
|
"assemblyVersion": "0.7.0.0",
|
||||||
|
"fileVersion": "0.7.0.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.Dialogs.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.Markup.Xaml.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.Markup.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.Metal.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.MicroCom.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.OpenGL.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.Vulkan.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
},
|
||||||
|
"lib/net8.0/Avalonia.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Angle.Windows.Natives/2.1.22045.20230930": {
|
||||||
|
"native": {
|
||||||
|
"runtimes/win-x64/native/av_libglesv2.dll": {
|
||||||
|
"fileVersion": "2.1.22045.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.BuildServices/0.0.29": {},
|
||||||
|
"Avalonia.Controls.DataGrid/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3",
|
||||||
|
"Avalonia.Remote.Protocol": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Controls.DataGrid.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Desktop/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3",
|
||||||
|
"Avalonia.Native": "11.2.3",
|
||||||
|
"Avalonia.Skia": "11.2.3",
|
||||||
|
"Avalonia.Win32": "11.2.3",
|
||||||
|
"Avalonia.X11": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Desktop.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Fonts.Inter/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Fonts.Inter.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.FreeDesktop/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3",
|
||||||
|
"Tmds.DBus.Protocol": "0.20.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.FreeDesktop.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Markup.Xaml.Loader/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Markup.Xaml.Loader.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Native/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Native.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Remote.Protocol/11.2.3": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Remote.Protocol.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Skia/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3",
|
||||||
|
"HarfBuzzSharp": "7.3.0.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": "7.3.0.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly": "7.3.0.3",
|
||||||
|
"SkiaSharp": "2.88.9",
|
||||||
|
"SkiaSharp.NativeAssets.Linux": "2.88.9",
|
||||||
|
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Skia.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Themes.Fluent/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Themes.Fluent.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Win32/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3",
|
||||||
|
"Avalonia.Angle.Windows.Natives": "2.1.22045.20230930"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.Win32.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.X11/11.2.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "11.2.3",
|
||||||
|
"Avalonia.FreeDesktop": "11.2.3",
|
||||||
|
"Avalonia.Skia": "11.2.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Avalonia.X11.dll": {
|
||||||
|
"assemblyVersion": "11.2.3.0",
|
||||||
|
"fileVersion": "11.2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp/7.3.0.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": "7.3.0.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": "7.3.0.3"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net6.0/HarfBuzzSharp.dll": {
|
||||||
|
"assemblyVersion": "1.0.0.0",
|
||||||
|
"fileVersion": "7.3.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux/7.3.0.3": {
|
||||||
|
"dependencies": {
|
||||||
|
"HarfBuzzSharp": "7.3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS/7.3.0.3": {},
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly/7.3.0.3": {},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32/7.3.0.3": {
|
||||||
|
"native": {
|
||||||
|
"runtimes/win-x64/native/libHarfBuzzSharp.dll": {
|
||||||
|
"fileVersion": "0.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MicroCom.Runtime/0.11.0": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net5.0/MicroCom.Runtime.dll": {
|
||||||
|
"assemblyVersion": "0.11.0.0",
|
||||||
|
"fileVersion": "0.11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp/2.88.9": {
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp.NativeAssets.Win32": "2.88.9",
|
||||||
|
"SkiaSharp.NativeAssets.macOS": "2.88.9"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net6.0/SkiaSharp.dll": {
|
||||||
|
"assemblyVersion": "2.88.0.0",
|
||||||
|
"fileVersion": "2.88.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux/2.88.9": {
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp": "2.88.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS/2.88.9": {},
|
||||||
|
"SkiaSharp.NativeAssets.WebAssembly/2.88.9": {},
|
||||||
|
"SkiaSharp.NativeAssets.Win32/2.88.9": {
|
||||||
|
"native": {
|
||||||
|
"runtimes/win-x64/native/libSkiaSharp.dll": {
|
||||||
|
"fileVersion": "0.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"System.IO.Pipelines/8.0.0": {
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/System.IO.Pipelines.dll": {
|
||||||
|
"assemblyVersion": "8.0.0.0",
|
||||||
|
"fileVersion": "8.0.23.53103"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Tmds.DBus.Protocol/0.20.0": {
|
||||||
|
"dependencies": {
|
||||||
|
"System.IO.Pipelines": "8.0.0"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"lib/net8.0/Tmds.DBus.Protocol.dll": {
|
||||||
|
"assemblyVersion": "0.20.0.0",
|
||||||
|
"fileVersion": "0.20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraries": {
|
||||||
|
"AvaloniaPayload/1.0.0": {
|
||||||
|
"type": "project",
|
||||||
|
"serviceable": false,
|
||||||
|
"sha512": ""
|
||||||
|
},
|
||||||
|
"Avalonia/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-pD6woFAUfGcyEvMmrpctntU4jv4fT8752pfx1J5iRORVX3Ob0oQi8PWo0TXVaAJZiSfH0cdKTeKx0w0DzD0/mg==",
|
||||||
|
"path": "avalonia/11.2.3",
|
||||||
|
"hashPath": "avalonia.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Angle.Windows.Natives/2.1.22045.20230930": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-Bo3qOhKC1b84BIhiogndMdAzB3UrrESKK7hS769f5HWeoMw/pcd42US5KFYW2JJ4ZSTrXnP8mXwLTMzh+S+9Lg==",
|
||||||
|
"path": "avalonia.angle.windows.natives/2.1.22045.20230930",
|
||||||
|
"hashPath": "avalonia.angle.windows.natives.2.1.22045.20230930.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.BuildServices/0.0.29": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-U4eJLQdoDNHXtEba7MZUCwrBErBTxFp6sUewXBOdAhU0Kwzwaa/EKFcYm8kpcysjzKtfB4S0S9n0uxKZFz/ikw==",
|
||||||
|
"path": "avalonia.buildservices/0.0.29",
|
||||||
|
"hashPath": "avalonia.buildservices.0.0.29.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Controls.DataGrid/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-Ul6oWEoqs4eTQZIb4Hzf0+ajhgrCX9ypOj8TahKbkKoIznJkUoka3iV90Vpj/AuoT5AZsN6f+1+62SVPpeMApA==",
|
||||||
|
"path": "avalonia.controls.datagrid/11.2.3",
|
||||||
|
"hashPath": "avalonia.controls.datagrid.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Desktop/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-dX3zfgWplLqcgwQJLeC2ciqxE/GM3iw9HUNI22c8KgAAWMWl52NWCmjW228EPZG+4YbHwq8T40YARO2aQF+yqA==",
|
||||||
|
"path": "avalonia.desktop/11.2.3",
|
||||||
|
"hashPath": "avalonia.desktop.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Fonts.Inter/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-ConIy0i4S5xuWwYMihJt+0VVIFS1NqXtMzG7XYFO/L786P9qXlQBnHHuLt4kdw5kvJtZEhgii9E+/W7b35wtoQ==",
|
||||||
|
"path": "avalonia.fonts.inter/11.2.3",
|
||||||
|
"hashPath": "avalonia.fonts.inter.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.FreeDesktop/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-BOivcAE3yYFKyYg5CztnTeIFX7ZHNaFiMrQ9WO4MgKyMwbPdH6jy6Mpfu+LY5FiYpleZdmXLJXZzzPon52DUVg==",
|
||||||
|
"path": "avalonia.freedesktop/11.2.3",
|
||||||
|
"hashPath": "avalonia.freedesktop.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Markup.Xaml.Loader/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-a11A13NtjNkGCqIO85q6YUN1uQFAwKHZwy7HtJt91+PujlewJ3+CO6Aw4evkyvg+rI7mcqyDE6FbCl3Juykqqg==",
|
||||||
|
"path": "avalonia.markup.xaml.loader/11.2.3",
|
||||||
|
"hashPath": "avalonia.markup.xaml.loader.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Native/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-yW9IGfa7kBuEcYP4ni7nGYNI2HjqaBg+cPJXZeiXf8RFptmluMv75hMyyq8FYIZwVcZIEcwEgff81a7b4aNTVQ==",
|
||||||
|
"path": "avalonia.native/11.2.3",
|
||||||
|
"hashPath": "avalonia.native.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Remote.Protocol/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-6V0aNtld48WmO8tAlWwlRlUmXYcOWv+1eJUSl1ETF+1blUe5yhcSmuWarPprO0hDk8Ta6wGfdfcrnVl2gITYcA==",
|
||||||
|
"path": "avalonia.remote.protocol/11.2.3",
|
||||||
|
"hashPath": "avalonia.remote.protocol.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Skia/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-jpzqkkmhzz6DSUy5dIw5T43MoHCdb05pmTvnsmHrbipA8mafI8RrO7tVnv1+ilFNV4516G9/kOpXjTLKjnnYrA==",
|
||||||
|
"path": "avalonia.skia/11.2.3",
|
||||||
|
"hashPath": "avalonia.skia.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Themes.Fluent/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-gPJWBWaeocvRhSrB977xsfH0Ame14PxRMIgEfezi2bTjNJ43JWzJtALgDfDZYMpZPDdeWU/mwDigR/kD+rJtlw==",
|
||||||
|
"path": "avalonia.themes.fluent/11.2.3",
|
||||||
|
"hashPath": "avalonia.themes.fluent.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.Win32/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-VwdaOHvIowTSM2umeXOFIoUx4UydCXkXracwLQZaMlsWXCTJ+WwtlAIv0ZBCwQccAK+WELrdRXucvWWN8+sJCQ==",
|
||||||
|
"path": "avalonia.win32/11.2.3",
|
||||||
|
"hashPath": "avalonia.win32.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Avalonia.X11/11.2.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-0mr3zu5NEv2cTLsANyc3w51ctiLTWQia6TrlDdWCjfMx2k0VtCzgGBieByPgUl4iNWEDzgBEKek1EwJcGdJ+7g==",
|
||||||
|
"path": "avalonia.x11/11.2.3",
|
||||||
|
"hashPath": "avalonia.x11.11.2.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp/7.3.0.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-Hq+5+gx10coOvuRgB13KBwiWxJq1QeYuhtVLbA01ZCWaugOnolUahF44KvrQTUUHDNk/C7HB6SMaebsZeOdhgg==",
|
||||||
|
"path": "harfbuzzsharp/7.3.0.3",
|
||||||
|
"hashPath": "harfbuzzsharp.7.3.0.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux/7.3.0.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-hkcHeTfOyIeJuPtO/QfoqkDvV/MXebZYaA/Bn/S+nXsjH3Wt9oQ6okH2kklYO+1UUdBSJFd67bi9IrpQXI2mPw==",
|
||||||
|
"path": "harfbuzzsharp.nativeassets.linux/7.3.0.3",
|
||||||
|
"hashPath": "harfbuzzsharp.nativeassets.linux.7.3.0.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS/7.3.0.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-UAwIYnkbBTzBJv1Id8FijY/i8QiIepRemSXufU8fyzwWhYJdx4+ajG8yQUie5HW/uusbVLFSr26muSlJOFDgSw==",
|
||||||
|
"path": "harfbuzzsharp.nativeassets.macos/7.3.0.3",
|
||||||
|
"hashPath": "harfbuzzsharp.nativeassets.macos.7.3.0.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly/7.3.0.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-OpheDNp9a3nC6hWNACemWkNEXJ4tWP3Gw9bykw3FbyeEmU2nUDtLIp6VgNnjHAPRMgUs1Kl7m4gJpzVYwC7CZw==",
|
||||||
|
"path": "harfbuzzsharp.nativeassets.webassembly/7.3.0.3",
|
||||||
|
"hashPath": "harfbuzzsharp.nativeassets.webassembly.7.3.0.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32/7.3.0.3": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-RPxRXD16KtSs8Yxr2RK9Qs7AwyN9MlpqZIYs0AvfaJwl7RAtVhC0+u2f2SKwX0uMYYd3O98Z+OBA1sj6aWVKQA==",
|
||||||
|
"path": "harfbuzzsharp.nativeassets.win32/7.3.0.3",
|
||||||
|
"hashPath": "harfbuzzsharp.nativeassets.win32.7.3.0.3.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"MicroCom.Runtime/0.11.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA==",
|
||||||
|
"path": "microcom.runtime/0.11.0",
|
||||||
|
"hashPath": "microcom.runtime.0.11.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"SkiaSharp/2.88.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
|
||||||
|
"path": "skiasharp/2.88.9",
|
||||||
|
"hashPath": "skiasharp.2.88.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux/2.88.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||||
|
"path": "skiasharp.nativeassets.linux/2.88.9",
|
||||||
|
"hashPath": "skiasharp.nativeassets.linux.2.88.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS/2.88.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==",
|
||||||
|
"path": "skiasharp.nativeassets.macos/2.88.9",
|
||||||
|
"hashPath": "skiasharp.nativeassets.macos.2.88.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.WebAssembly/2.88.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA==",
|
||||||
|
"path": "skiasharp.nativeassets.webassembly/2.88.9",
|
||||||
|
"hashPath": "skiasharp.nativeassets.webassembly.2.88.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Win32/2.88.9": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==",
|
||||||
|
"path": "skiasharp.nativeassets.win32/2.88.9",
|
||||||
|
"hashPath": "skiasharp.nativeassets.win32.2.88.9.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"System.IO.Pipelines/8.0.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==",
|
||||||
|
"path": "system.io.pipelines/8.0.0",
|
||||||
|
"hashPath": "system.io.pipelines.8.0.0.nupkg.sha512"
|
||||||
|
},
|
||||||
|
"Tmds.DBus.Protocol/0.20.0": {
|
||||||
|
"type": "package",
|
||||||
|
"serviceable": true,
|
||||||
|
"sha512": "sha512-2gkt2kuYPhDKd8gtl34jZSJOnn4nRJfFngCDcTZT/uySbK++ua0YQx2418l9Rn1Y4dE5XNq6zG9ZsE5ltLlNNw==",
|
||||||
|
"path": "tmds.dbus.protocol/0.20.0",
|
||||||
|
"hashPath": "tmds.dbus.protocol.0.20.0.nupkg.sha512"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using Microsoft.Identity.Client;
|
||||||
|
|
||||||
|
public class HttpFactoryWithProxy : IMsalHttpClientFactory
|
||||||
|
{
|
||||||
|
private static HttpClient _httpClient;
|
||||||
|
|
||||||
|
public HttpFactoryWithProxy(string proxyURI) : this(proxyURI, null, null)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpFactoryWithProxy(string proxyURI, string proxyUserName = null, string proxyPassword = null)
|
||||||
|
{
|
||||||
|
if (_httpClient == null)
|
||||||
|
{
|
||||||
|
var proxy = new WebProxy
|
||||||
|
{
|
||||||
|
Address = new Uri(proxyURI),
|
||||||
|
BypassProxyOnLocal = false,
|
||||||
|
UseDefaultCredentials = false,
|
||||||
|
Credentials = new NetworkCredential(
|
||||||
|
userName: proxyUserName,
|
||||||
|
password: proxyPassword)
|
||||||
|
};
|
||||||
|
|
||||||
|
var httpClientHandler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
Proxy = proxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpClient = new HttpClient(handler: httpClientHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient GetHttpClient()
|
||||||
|
{
|
||||||
|
return _httpClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
#ImportOrder 26
|
||||||
|
|
||||||
|
# MSAL implementation of AuthenticationProvider.
|
||||||
|
#
|
||||||
|
# Naming convention: every concrete auth provider is named Authentication<Backend>
|
||||||
|
# so they sort together in the Classes/ folder (AuthenticationMgGraph, AuthenticationOAuth).
|
||||||
|
#
|
||||||
|
# This class is a facade over the MSAL functions in Internal/AuthenticationMSALHelpers.ps1
|
||||||
|
# (Connect-EntraEnvironment / Connect-WithClientCredentials / Get-FullToken). Consumers
|
||||||
|
# reach MSAL through the provider abstraction: Invoke-MSGraphAPI resolves a call's owning
|
||||||
|
# provider by TokenId and asks it for the bearer via GetAccessToken.
|
||||||
|
class AuthenticationMSAL : AuthenticationProvider {
|
||||||
|
|
||||||
|
# TokenIds currently inside a pre-flight silent refresh. Used by GetAccessToken
|
||||||
|
# to break the re-entrancy cycle: Connect-EntraEnvironment itself calls back
|
||||||
|
# into Graph (Organization / ME / photo) and those calls land here for the
|
||||||
|
# bearer header. Without a guard the nested call sees the still-cached
|
||||||
|
# expired token and recurses into Connect-EntraEnvironment forever — caught
|
||||||
|
# in the wild as a "call depth overflow" crash. The first-in caller drives
|
||||||
|
# the refresh; nested calls return the cached bearer (which Connect's inner
|
||||||
|
# Invoke-MSGraphAPI -SkipAuthentication can already cope with).
|
||||||
|
static [hashtable]$Refreshing = @{}
|
||||||
|
|
||||||
|
AuthenticationMSAL() {
|
||||||
|
$this.Id = "MSAL"
|
||||||
|
$this.DisplayName = "Microsoft Authentication Library"
|
||||||
|
|
||||||
|
# Required capabilities — all true (Interactive / ClientSecret / Certificate
|
||||||
|
# default to $true on the base class). Set explicitly here as a contract
|
||||||
|
# marker so a future edit can't accidentally turn one off.
|
||||||
|
$this.SupportsInteractive = $true
|
||||||
|
$this.SupportsClientSecret = $true
|
||||||
|
$this.SupportsCertificate = $true
|
||||||
|
|
||||||
|
# Optional capability: MSAL.NET supports federated credentials via
|
||||||
|
# WithClientAssertion + managed identity via WithAzureMSI, but
|
||||||
|
# Connect-IntuneManagement does NOT expose those paths yet. Flag stays
|
||||||
|
# $false until the parameter sets are added.
|
||||||
|
$this.SupportsIdentityProvider = $false
|
||||||
|
|
||||||
|
# MSAL accepts BYO bearer tokens through Add-BYOTokenInfo.
|
||||||
|
$this.SupportsBYOToken = $true
|
||||||
|
|
||||||
|
# MSAL re-mints tokens for CAE claims challenges (silent, escalating to
|
||||||
|
# interactive when the caller allows it). See GetClaimsToken.
|
||||||
|
$this.SupportsClaimsChallenge = $true
|
||||||
|
|
||||||
|
# This provider's flows run through the built-in Connect-EntraEnvironment /
|
||||||
|
# Connect-WithClientCredentials entry points (see UsesBuiltInConnectPath on the
|
||||||
|
# base): Connect-IntuneManagement, the interactive-login helper, and the profile
|
||||||
|
# Refresh action drive MSAL through those functions directly, keeping the rich
|
||||||
|
# cloud/token-id handling and behaviour identical to the pre-abstraction path.
|
||||||
|
$this.UsesBuiltInConnectPath = $true
|
||||||
|
|
||||||
|
# MSAL can launch an interactive consent prompt via Start-MSALConsentPrompt.
|
||||||
|
$this.SupportsConsentPrompt = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# No initialization work — MSAL DLLs and settings are wired by Invoke-MSALInitialize
|
||||||
|
# which runs from AuthenticationMSALHelpers.ps1 at module load.
|
||||||
|
[void] Initialize() { }
|
||||||
|
|
||||||
|
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||||
|
# Two entry points historically:
|
||||||
|
# Connect-IntuneManagement : public API with explicit Secret / Certificate / Token
|
||||||
|
# Connect-EntraEnvironment : internal — interactive, silent, refresh
|
||||||
|
# Pick based on which fields are present in $Arguments.
|
||||||
|
|
||||||
|
# Copy first so any Cloud→legacy translation we do here doesn't mutate the
|
||||||
|
# caller's hashtable. We translate Cloud→GraphEnvironment+GCCType because the
|
||||||
|
# downstream MSAL functions haven't migrated to the new enum yet (Phase 4).
|
||||||
|
$local = @{}
|
||||||
|
foreach($key in $Arguments.Keys) { $local[$key] = $Arguments[$key] }
|
||||||
|
if($local.ContainsKey('Cloud') -and $local.Cloud -and -not $local.ContainsKey('GraphEnvironment')) {
|
||||||
|
$entry = Get-CloudByValue ([string]$local.Cloud)
|
||||||
|
if($entry) {
|
||||||
|
$local['GraphEnvironment'] = $entry.LegacyEnv
|
||||||
|
if($entry.LegacyGCC) { $local['GCCType'] = $entry.LegacyGCC }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($local.ContainsKey('Secret') -or $local.ContainsKey('Certificate') -or
|
||||||
|
$local.ContainsKey('CertificatePath') -or $local.ContainsKey('Token')) {
|
||||||
|
# Connect-IntuneManagement now understands -Cloud directly; we still pass
|
||||||
|
# the legacy params for compatibility (Connect-IntuneManagement re-resolves
|
||||||
|
# them with -Cloud taking precedence).
|
||||||
|
return (Connect-IntuneManagement @local)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Connect-EntraEnvironment uses the internal -Environment parameter (the MSAL
|
||||||
|
# function predates our public taxonomy). Translate before splatting so the
|
||||||
|
# cloud picker dialog's choice actually reaches MSAL. GCCType is NOT a
|
||||||
|
# Connect-EntraEnvironment parameter (only Connect-WithClientCredentials
|
||||||
|
# / Add-BYOTokenInfo take it) — splatting it triggers "Cannot bind
|
||||||
|
# positional parameters"; drop it. Cloud IS a parameter on
|
||||||
|
# Connect-EntraEnvironment now, so we no longer need to drop it either.
|
||||||
|
if($local.ContainsKey('GraphEnvironment')) {
|
||||||
|
$local['Environment'] = $local['GraphEnvironment']
|
||||||
|
$local.Remove('GraphEnvironment') | Out-Null
|
||||||
|
}
|
||||||
|
if($local.ContainsKey('GCCType')) { $local.Remove('GCCType') | Out-Null }
|
||||||
|
return (Connect-EntraEnvironment @local)
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Disconnect([int]$TokenId) {
|
||||||
|
try {
|
||||||
|
Disconnect-EntraEnvironment -TokenID $TokenId
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "MSAL Disconnect failed for TokenId $TokenId" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Refresh([int]$TokenId) {
|
||||||
|
try {
|
||||||
|
return [bool](Connect-EntraEnvironment -TokenId $TokenId -ForceRefresh)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "MSAL Refresh failed for TokenId $TokenId" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# MSAL is the one provider that can do this: the same account can mint a second
|
||||||
|
# token for the Azure Resource Manager audience, which is the only API that
|
||||||
|
# enumerates a user's tenants (see Internal/EntraTenantList.ps1 for why Graph
|
||||||
|
# cannot). Returns the result object that file documents, or $null when there
|
||||||
|
# is no usable MSAL session to ask with.
|
||||||
|
[PSCustomObject] GetAccessibleTenants([int]$TokenId) {
|
||||||
|
$tokenInfo = Get-FullToken $TokenId
|
||||||
|
if(-not $tokenInfo -or -not $tokenInfo.App -or -not $tokenInfo.Token -or -not $tokenInfo.Token.Account) {
|
||||||
|
Write-LogDebug "MSAL GetAccessibleTenants: no usable token for id $TokenId"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only offer the no-prompt interactive fallback once the UI is up; in a
|
||||||
|
# script there is nobody to answer a window that may appear.
|
||||||
|
$interactive = ($script:MainAppStarted -eq $true)
|
||||||
|
|
||||||
|
return (Get-EntraAccessibleTenant -App $tokenInfo.App -Account $tokenInfo.Token.Account `
|
||||||
|
-TenantId $tokenInfo.Token.TenantId -Cloud $tokenInfo.Cloud -AllowInteractive:$interactive)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Silent startup resume — re-establish the last session from the persisted MSAL
|
||||||
|
# cache without prompting. Invoked once from Invoke-AuthCoreOnAppInitialized for
|
||||||
|
# the active provider; restores the old Connect-MSALUser -Silent startup logon
|
||||||
|
# that made the app auto-sign-in on launch. The fresh (no -TokenId) path resolves
|
||||||
|
# the account from the on-disk cache via GetAccountsAsync matched against the
|
||||||
|
# persisted LastLoggedOnUserId. -ForceSilent guarantees no interactive prompt: if
|
||||||
|
# there is no cached account (or the broker can't silently reissue), it simply
|
||||||
|
# returns $false and the user signs in manually. -DefaultToken makes the resumed
|
||||||
|
# session the active default so the UI shows signed-in.
|
||||||
|
[bool] TryResumeSession() {
|
||||||
|
# Respect the "Remember Login" toggle — if the user disabled caching there is
|
||||||
|
# nothing to resume and we should not touch the account cache.
|
||||||
|
if(-not (Get-SettingValue "CacheMSALToken")) { return $false }
|
||||||
|
# Cheap probe (settings + file existence only) BEFORE the MSAL runtime loads:
|
||||||
|
# a fresh box with no cached session skips the DLL load entirely.
|
||||||
|
if(-not (Test-MSALResumeLikely)) {
|
||||||
|
Write-LogDebug "MSAL TryResumeSession skipped - no cached session to resume"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$result = Connect-EntraEnvironment -ForceSilent -DefaultToken
|
||||||
|
return [bool]$result
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "MSAL TryResumeSession failed" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Silent ambient refresh on view activation. Gated on the base no-op for other
|
||||||
|
# providers so MgGraph mode isn't hijacked (a successful silent MSAL auth here would
|
||||||
|
# flip the active provider). No -DefaultToken: this only refreshes, never promotes.
|
||||||
|
[void] RefreshAmbientSession() {
|
||||||
|
Connect-EntraEnvironment -ForceSilent | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Native session inspector rows for the profile "Session Info" dialog: the MSAL
|
||||||
|
# AuthenticationResult fields (minus the raw tokens).
|
||||||
|
# Decoded id-token JWT for the profile popup's Id Token inspector. Reads MSAL's
|
||||||
|
# own token entry from the registry; returns $null when there's no id token so the
|
||||||
|
# UI hides the button. This keeps the id-token JWT confined to the MSAL provider.
|
||||||
|
[object] GetIdTokenJwt([int]$TokenId) {
|
||||||
|
$t = Get-FullToken $TokenId
|
||||||
|
if($t -and $t.JWTIdToken) { return $t.JWTIdToken }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetSessionInfoRows() {
|
||||||
|
$rows = @()
|
||||||
|
if($script:MSALDefaultToken -and $script:MSALDefaultToken.Token) {
|
||||||
|
foreach($prop in ($script:MSALDefaultToken.Token | Get-Member | Where-Object MemberType -eq Property)) {
|
||||||
|
if($prop.Name -in @("AccessToken", "IdToken")) { continue }
|
||||||
|
$value = if($prop.Name -eq "Scopes") { ($script:MSALDefaultToken.Token.Scopes -join "`n") }
|
||||||
|
elseif($prop.Name -in @("ExpiresOn", "ExtendedExpiresOn")) { $script:MSALDefaultToken.Token."$($prop.Name)".LocalDateTime }
|
||||||
|
else { $script:MSALDefaultToken.Token."$($prop.Name)" }
|
||||||
|
$rows += [PSCustomObject]@{ Name = $prop.Name; Value = $value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [PSCustomObject[]]$rows
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] ForgetAccount([string]$AccountIdentifier) {
|
||||||
|
if(-not $script:MSALAccounts) { return $false }
|
||||||
|
$account = $script:MSALAccounts | Where-Object {
|
||||||
|
$_.Username -eq $AccountIdentifier -or
|
||||||
|
$_.HomeAccountId.Identifier -eq $AccountIdentifier
|
||||||
|
} | Select-Object -First 1
|
||||||
|
if(-not $account) { return $false }
|
||||||
|
Remove-MSALAccount -Account $account
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||||
|
$tok = Get-FullToken $TokenId
|
||||||
|
if(-not $tok -or -not $tok.Token) { return $null }
|
||||||
|
|
||||||
|
# Pre-flight refresh near expiry to avoid mid-batch 401s. Today the resource is
|
||||||
|
# always Graph (the MSAL token caches a single audience); when other resources
|
||||||
|
# are supported in a future phase, the provider should mint per-resource tokens
|
||||||
|
# here via AcquireTokenSilent.WithScopes(resource/.default).
|
||||||
|
#
|
||||||
|
# Re-entrancy guard: Connect-EntraEnvironment internally calls Invoke-MSGraphAPI
|
||||||
|
# ('Organization', 'ME', photo) before its own returns; those calls land back
|
||||||
|
# in this method for the bearer header. The cached token is still the expired
|
||||||
|
# one at that point — the new token isn't installed until Connect's
|
||||||
|
# Add-MSALTokenInfo runs at the tail. Without a guard the nested call retries
|
||||||
|
# the refresh, which calls Invoke-MSGraphAPI, which re-enters here, etc., until
|
||||||
|
# PowerShell's call-depth limit aborts.
|
||||||
|
if($tok.Token.ExpiresOn -lt [DateTimeOffset]::UtcNow.AddMinutes(5) -and
|
||||||
|
-not [AuthenticationMSAL]::Refreshing.ContainsKey($TokenId)) {
|
||||||
|
[AuthenticationMSAL]::Refreshing[$TokenId] = $true
|
||||||
|
# Only let a refresh miss raise AuthenticationFailed once the token has
|
||||||
|
# ACTUALLY expired. Within the 5-minute pre-flight window the current token
|
||||||
|
# is still usable, so a silent-refresh miss (e.g. the WAM broker failing on
|
||||||
|
# the refresh round-trip) must stay quiet - otherwise every near-expiry Graph
|
||||||
|
# call falsely reports a failed login even though the call then succeeds on
|
||||||
|
# the still-valid token. When truly expired, stay loud so the UI signs out.
|
||||||
|
$tokenStillValid = $tok.Token.ExpiresOn -gt [DateTimeOffset]::UtcNow
|
||||||
|
try {
|
||||||
|
# Plain silent acquire (NO -ForceRefresh). AcquireTokenSilent already
|
||||||
|
# renews an expired/near-expired access token from the refresh token
|
||||||
|
# (or via the WAM broker) on its own. Forcing a refresh here made the
|
||||||
|
# broker re-contact Entra ~5 min before every expiry, and WAM can surface
|
||||||
|
# an interactive window on that forced round-trip - that was the ~70-min
|
||||||
|
# re-login. The old 3.9.6 build never force-refreshed routinely (only on
|
||||||
|
# an explicit user "Force refresh" link); this matches it. -ForceRefresh
|
||||||
|
# is still used by Refresh() and the UI Refresh button where it is wanted.
|
||||||
|
[void](Connect-EntraEnvironment -TokenId $TokenId -ForceSilent -SuppressFailedEvent:$tokenStillValid)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
[AuthenticationMSAL]::Refreshing.Remove($TokenId) | Out-Null
|
||||||
|
}
|
||||||
|
$tok = Get-FullToken $TokenId
|
||||||
|
if(-not $tok -or -not $tok.Token) { return $null }
|
||||||
|
}
|
||||||
|
return $tok.Token.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
# Satisfy a CAE claims challenge. Silent re-acquire first (broker/WAM can often
|
||||||
|
# satisfy a CAE / sign-in-frequency challenge without a visible prompt); if that
|
||||||
|
# fails and the caller allows interaction, escalate to an interactive acquire with
|
||||||
|
# the same claims so the challenge is met with a single prompt instead of a dead
|
||||||
|
# 401. When $AllowInteractive is $false (headless / nested auth-flow call) this
|
||||||
|
# stays silent-only and returns $null if the challenge can't be met.
|
||||||
|
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
|
||||||
|
[void](Connect-EntraEnvironment -TokenId $TokenId -ClaimsChallenge $ClaimsChallenge -ForceSilent)
|
||||||
|
$token = $this.GetAccessToken($TokenId, $Resource)
|
||||||
|
|
||||||
|
if(-not $token -and $AllowInteractive) {
|
||||||
|
Write-Log "CAE challenge could not be satisfied silently. Escalating to interactive login." 2
|
||||||
|
[void](Connect-EntraEnvironment -TokenId $TokenId -ClaimsChallenge $ClaimsChallenge -ForceInteractive)
|
||||||
|
$token = $this.GetAccessToken($TokenId, $Resource)
|
||||||
|
}
|
||||||
|
return $token
|
||||||
|
}
|
||||||
|
|
||||||
|
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||||
|
$tok = Get-FullToken $TokenId
|
||||||
|
if(-not $tok -or -not $tok.Token -or -not $tok.Token.ExpiresOn) {
|
||||||
|
return [datetime]::MaxValue
|
||||||
|
}
|
||||||
|
return $tok.Token.ExpiresOn.LocalDateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||||
|
$tok = Get-FullToken $TokenId
|
||||||
|
if(-not $tok) { return $null }
|
||||||
|
|
||||||
|
$authType = if($tok.AuthType) { $tok.AuthType } else { "Interactive" }
|
||||||
|
$expires = $null
|
||||||
|
if($tok.Token -and $tok.Token.ExpiresOn) { $expires = $tok.Token.ExpiresOn.LocalDateTime }
|
||||||
|
|
||||||
|
$upn = $null
|
||||||
|
if($tok.Token -and $tok.Token.Account) { $upn = $tok.Token.Account.Username }
|
||||||
|
|
||||||
|
$userId = $null
|
||||||
|
if($tok.Token -and $tok.Token.Account -and $tok.Token.Account.HomeAccountId) {
|
||||||
|
$userId = $tok.Token.Account.HomeAccountId.ObjectId
|
||||||
|
}
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Provider = $this.Id
|
||||||
|
DisplayName = $upn
|
||||||
|
UPN = $upn
|
||||||
|
UserId = $userId
|
||||||
|
TenantId = (?: $tok.Token $tok.Token.TenantId $null)
|
||||||
|
TenantName = (?: $tok.Organization $tok.Organization.displayName $null)
|
||||||
|
AppId = (?: $tok.EntraApp $tok.EntraApp.ClientId $null)
|
||||||
|
AppName = (?: $tok.EntraApp $tok.EntraApp.Name $null)
|
||||||
|
AuthType = $authType
|
||||||
|
ExpiresOn = $expires
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetCachedAccounts() {
|
||||||
|
# Lazy-refresh from the on-disk MSAL cache. Same trick the UI already does in
|
||||||
|
# Get-MSALUserProfile, but exposed at the provider level so any consumer
|
||||||
|
# (CLI scripts, automation) sees the same list.
|
||||||
|
if(($script:MSALAccounts | Measure-Object).Count -eq 0) {
|
||||||
|
try {
|
||||||
|
$app = $script:MSALApps | Select-Object -First 1
|
||||||
|
if(-not $app) { $app = New-MSALApp }
|
||||||
|
if($app) {
|
||||||
|
$script:MSALAccounts = $app.GetAccountsAsync().GetAwaiter().GetResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogDebug "MSAL GetCachedAccounts refresh failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(-not $script:MSALAccounts) { return [PSCustomObject[]]@() }
|
||||||
|
|
||||||
|
$rows = foreach($acc in $script:MSALAccounts) {
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Provider = $this.Id
|
||||||
|
Username = $acc.Username
|
||||||
|
UserId = $acc.HomeAccountId.ObjectId
|
||||||
|
TenantId = $acc.HomeAccountId.TenantId
|
||||||
|
Native = $acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [PSCustomObject[]]@($rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
||||||
|
$tok = Get-FullToken $TokenId
|
||||||
|
if(-not $tok -or -not $tok.Tenants) { return [PSCustomObject[]]@() }
|
||||||
|
|
||||||
|
$rows = foreach($t in $tok.Tenants) {
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Provider = $this.Id
|
||||||
|
TenantId = $t.tenantId
|
||||||
|
TenantName = $t.displayName
|
||||||
|
Native = $t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [PSCustomObject[]]@($rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
#ImportOrder 26
|
||||||
|
|
||||||
|
# Microsoft.Graph PowerShell SDK implementation of AuthenticationProvider.
|
||||||
|
#
|
||||||
|
# Wraps the Microsoft.Graph.Authentication module (Connect-MgGraph, Disconnect-MgGraph,
|
||||||
|
# Get-MgContext). The SDK itself uses MSAL.NET underneath but with its own session
|
||||||
|
# state and its own on-disk token cache, separate from our MSAL provider — by design,
|
||||||
|
# per the user's decision to keep caches separate.
|
||||||
|
#
|
||||||
|
# Status:
|
||||||
|
# * The class always registers (even without the SDK installed) so it is selectable
|
||||||
|
# in Settings; the SDK modules are resolved / prompted-for on first Connect.
|
||||||
|
# * `Connect-IntuneManagement -Provider MgGraph -...` routes here.
|
||||||
|
# * Invoke-MSGraphAPI routes requests for MgGraph-owned tokens through this provider:
|
||||||
|
# when the SDK's opaque cache can't yield a raw bearer, the request runs via the
|
||||||
|
# provider's own pipeline (see InvokeWebRequest / Invoke-MgGraphRequestAsWebResponse).
|
||||||
|
#
|
||||||
|
# Token-extraction note: the SDK does not expose the raw access token via a public
|
||||||
|
# cmdlet. We reach into [Microsoft.Graph.PowerShell.Authentication.GraphSession]::Instance
|
||||||
|
# which is the documented (in source) but undocumented (in MS Learn) accessor. In SDK
|
||||||
|
# v2 the AccessToken is a SecureString; we unprotect at the last moment.
|
||||||
|
#
|
||||||
|
# Minimum SDK version for CAE: Microsoft.Graph.Authentication 2.37.0+ is recommended.
|
||||||
|
# Earlier versions had a token-cache bug (fixed by PR #3573, May 2026) where the
|
||||||
|
# `caeEnabled: true` capability was not included when caching tokens — so a CAE
|
||||||
|
# claim-challenge round-trip could re-prompt instead of resolving silently. Older
|
||||||
|
# SDK versions still work for non-CAE flows.
|
||||||
|
class AuthenticationMgGraph : AuthenticationProvider {
|
||||||
|
|
||||||
|
AuthenticationMgGraph() {
|
||||||
|
$this.Id = "MgGraph"
|
||||||
|
$this.DisplayName = "Microsoft Graph PowerShell SDK"
|
||||||
|
|
||||||
|
# Required capabilities (see AuthenticationProvider contract).
|
||||||
|
$this.SupportsInteractive = $true
|
||||||
|
$this.SupportsClientSecret = $true
|
||||||
|
$this.SupportsCertificate = $true
|
||||||
|
|
||||||
|
# Optional: SDK has -Identity flag for managed identity.
|
||||||
|
$this.SupportsIdentityProvider = $true
|
||||||
|
|
||||||
|
# Optional: SDK accepts -AccessToken.
|
||||||
|
$this.SupportsBYOToken = $true
|
||||||
|
|
||||||
|
# The SDK keeps cached accounts in a private InMemoryTokenCache byte[] (the
|
||||||
|
# serialized MSAL-v3 cache). GetCachedAccounts() reaches in via reflection
|
||||||
|
# and rehydrates an MSAL public-client app to enumerate the accounts —
|
||||||
|
# source pattern: github.com/microsoftgraph/msgraph-sdk-powershell.
|
||||||
|
# NOTE: this cache is in-memory only (NOT persisted to disk) — accounts only
|
||||||
|
# show up within the current PowerShell session, and clicking one cannot
|
||||||
|
# "switch to" that account because Connect-MgGraph has no -LoginHint
|
||||||
|
# parameter. The list is informational; the user must re-Connect-MgGraph
|
||||||
|
# to change accounts.
|
||||||
|
$this.SupportsCachedUsers = $true
|
||||||
|
|
||||||
|
# SDK has no per-call tenant switching — you Disconnect and Connect with a
|
||||||
|
# different -TenantId. The active session is single-tenant.
|
||||||
|
$this.SupportsMultiTenant = $false
|
||||||
|
|
||||||
|
# The SDK refreshes internally, but a manual "Refresh" action is meaningful
|
||||||
|
# for users — we re-trigger Connect-MgGraph (silent if the cache has a
|
||||||
|
# valid refresh token, interactive otherwise).
|
||||||
|
$this.SupportsRefresh = $true
|
||||||
|
|
||||||
|
# No way to evict a single cached account from the SDK's token cache via
|
||||||
|
# public cmdlets. Disconnect-MgGraph clears the active session only.
|
||||||
|
$this.SupportsForget = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] Initialize() {
|
||||||
|
# Module presence check happens at registration time in
|
||||||
|
# Internal/AuthenticationMgGraphHelpers.ps1 — by the time we get here, the SDK is
|
||||||
|
# known to be installed. We do NOT eagerly Import-Module (load cost is
|
||||||
|
# ~hundreds of ms); the first Connect() call imports lazily.
|
||||||
|
}
|
||||||
|
|
||||||
|
# The SDK v2 in-memory token cache is opaque, so we can't hand Invoke-MSGraphAPI a
|
||||||
|
# raw bearer. Route the request through the SDK's own pipeline (which auths it) and
|
||||||
|
# wrap the result so it quacks like Invoke-WebRequest's response.
|
||||||
|
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
|
||||||
|
return (Invoke-MgGraphRequestAsWebResponse -Url $Url -Method $Method -Body $Body -Headers $Headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Native session inspector rows for the profile "Session Info" dialog: Get-MgContext
|
||||||
|
# properties (the closest MgGraph equivalent of MSAL's AuthenticationResult).
|
||||||
|
[PSCustomObject[]] GetSessionInfoRows() {
|
||||||
|
$rows = @()
|
||||||
|
try {
|
||||||
|
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||||
|
if($ctx) {
|
||||||
|
foreach($prop in ($ctx | Get-Member -MemberType Properties)) {
|
||||||
|
$value = $ctx."$($prop.Name)"
|
||||||
|
if($prop.Name -eq "Scopes" -and $value) { $value = ($value -join "`n") }
|
||||||
|
if($value -is [SecureString]) { $value = "<SecureString>" }
|
||||||
|
$rows += [PSCustomObject]@{ Name = $prop.Name; Value = $value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return [PSCustomObject[]]$rows
|
||||||
|
}
|
||||||
|
|
||||||
|
# Silent cross-session resume. Microsoft.Graph SDK v2 persists credentials by
|
||||||
|
# default (ContextScope.CurrentUser): the MSAL cache lives at
|
||||||
|
# %LOCALAPPDATA%\.IdentityService\mg.msal.cache and the AuthenticationRecord
|
||||||
|
# anchor at %USERPROFILE%\.mg\mg.authrecord.json. Both must exist; if so, a
|
||||||
|
# plain Connect-MgGraph -NoWelcome silently rehydrates the session via
|
||||||
|
# Azure.Identity's MsalCacheHelper. No browser, no prompt.
|
||||||
|
[bool] TryResumeSession() {
|
||||||
|
try {
|
||||||
|
if(-not (Resolve-MgGraphModule)) { return $false }
|
||||||
|
|
||||||
|
$anchor = Join-Path $env:USERPROFILE ".mg\mg.authrecord.json"
|
||||||
|
if(-not (Test-Path $anchor)) {
|
||||||
|
Write-LogDebug "MgGraph: no auth record at $anchor - skipping silent resume"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "MgGraph TryResumeSession: failed to import Microsoft.Graph.Authentication" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# If a context is already active (e.g. another caller already connected
|
||||||
|
# during this session), respect it.
|
||||||
|
$existing = $null
|
||||||
|
try { $existing = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||||
|
if($existing) {
|
||||||
|
Write-Log "MgGraph: already signed in as $($existing.Account) (tenant $($existing.TenantId)); silent resume not needed"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log "MgGraph: attempting silent resume from persisted Azure.Identity cache..."
|
||||||
|
|
||||||
|
# NoWelcome suppresses banner; no Scopes parameter means Azure.Identity
|
||||||
|
# uses whatever scopes were in the AuthenticationRecord. If the cache or
|
||||||
|
# record is stale, Connect-MgGraph will throw / require interaction —
|
||||||
|
# we treat any failure as "resume not possible, user must click Login".
|
||||||
|
Connect-MgGraph -NoWelcome -ErrorAction Stop | Out-Null
|
||||||
|
|
||||||
|
$ctx = $null
|
||||||
|
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||||
|
if($ctx) {
|
||||||
|
Write-Log "MgGraph: silent resume succeeded - signed in as $($ctx.Account) (tenant $($ctx.TenantId))"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
Write-Log "MgGraph: Connect-MgGraph completed but Get-MgContext returned nothing - silent resume failed" 2
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogDebug "MgGraph: silent resume failed: $($_.Exception.Message)"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||||
|
Write-Log "AuthenticationMgGraph.Connect starting"
|
||||||
|
|
||||||
|
# Step 1: ensure the required SDK module is available. If not, offer to
|
||||||
|
# install it. If the user declines or install fails, return $null so
|
||||||
|
# Connect-IntuneManagement can fall back to MSAL.
|
||||||
|
if(-not (Resolve-MgGraphModule)) {
|
||||||
|
Write-Log "Microsoft.Graph.Authentication not available - MgGraph provider cannot connect" 2
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "Failed to import Microsoft.Graph.Authentication" $_.Exception
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$mgArgs = @{ NoWelcome = $true }
|
||||||
|
|
||||||
|
if($Arguments.TenantId) { $mgArgs['TenantId'] = $Arguments.TenantId }
|
||||||
|
if($Arguments.AppId) { $mgArgs['ClientId'] = $Arguments.AppId }
|
||||||
|
|
||||||
|
# Cloud / sovereign environment selection. Prefer the flat -Cloud arg
|
||||||
|
# (Phase 1, 2026-05-22). Fall back to translating the legacy GraphEnvironment+GCCType
|
||||||
|
# pair so direct provider callers passing the old shape still work during the
|
||||||
|
# deprecation window. Connect-MgGraph -Environment accepts: Global / USGov / USGovDOD / China.
|
||||||
|
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud }
|
||||||
|
elseif($Arguments.GraphEnvironment -or $Arguments.GCCType) {
|
||||||
|
Convert-LegacyToCloud -GraphEnvironment ([string]$Arguments.GraphEnvironment) -GCCType ([string]$Arguments.GCCType)
|
||||||
|
}
|
||||||
|
else { "Public" }
|
||||||
|
|
||||||
|
$cloudEntry = Get-CloudByValue $cloudValue
|
||||||
|
$mgEnv = $cloudEntry.MgEnvironment
|
||||||
|
if($mgEnv -and $mgEnv -ne "Global") {
|
||||||
|
$mgArgs['Environment'] = $mgEnv
|
||||||
|
Write-LogDebug "MgGraph: using -Environment $mgEnv (Cloud=$cloudValue)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dispatch on auth method. Connect-MgGraph parameter sets are mutually
|
||||||
|
# exclusive, so we pick exactly one.
|
||||||
|
if($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
|
||||||
|
if(-not $Arguments.AppId) { Write-Log "MgGraph: -AppId required with -Secret" 3; return $null }
|
||||||
|
if(-not $Arguments.TenantId) { Write-Log "MgGraph: -TenantId required with -Secret" 3; return $null }
|
||||||
|
$secStr = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret }
|
||||||
|
else { ConvertTo-SecureString ([string]$Arguments.Secret) -AsPlainText -Force }
|
||||||
|
$mgArgs['ClientSecretCredential'] = [PSCredential]::new($Arguments.AppId, $secStr)
|
||||||
|
# ClientId + TenantId are conveyed via the credential here; remove the
|
||||||
|
# standalone entries so we don't conflict with the credential parameter set.
|
||||||
|
$mgArgs.Remove('ClientId') | Out-Null
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
|
||||||
|
if($Arguments.Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
|
||||||
|
$mgArgs['Certificate'] = $Arguments.Certificate
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Treat as thumbprint string
|
||||||
|
$mgArgs['CertificateThumbprint'] = [string]$Arguments.Certificate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
|
||||||
|
$cert = Get-PfxCertificate -FilePath $Arguments.CertificatePath -Password $Arguments.CertificatePassword -ErrorAction Stop
|
||||||
|
$mgArgs['Certificate'] = $cert
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('Token') -and $Arguments.Token) {
|
||||||
|
$tokStr = if($Arguments.Token -is [SecureString]) { $Arguments.Token }
|
||||||
|
else { ConvertTo-SecureString ([string]$Arguments.Token) -AsPlainText -Force }
|
||||||
|
$mgArgs['AccessToken'] = $tokStr
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity) {
|
||||||
|
$mgArgs['Identity'] = $true
|
||||||
|
# For user-assigned managed identity, the user can pass a specific client id.
|
||||||
|
if($Arguments.ManagedIdentityClientId) { $mgArgs['ClientId'] = $Arguments.ManagedIdentityClientId }
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) {
|
||||||
|
# Microsoft.Graph.Authentication 2.x exposes device code as the
|
||||||
|
# -UseDeviceCode switch on Connect-MgGraph. Emits the code and
|
||||||
|
# verification URL to the console and blocks until the user
|
||||||
|
# completes auth in a browser on any device.
|
||||||
|
$mgArgs['UseDeviceCode'] = $true
|
||||||
|
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Interactive. Default scopes match what the rest of the app uses; callers
|
||||||
|
# can override via $Arguments.Scopes.
|
||||||
|
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
|
||||||
|
}
|
||||||
|
|
||||||
|
$authModeLog = if($mgArgs.ContainsKey('ClientSecretCredential')) { 'client secret' }
|
||||||
|
elseif($mgArgs.ContainsKey('Certificate')) { 'certificate' }
|
||||||
|
elseif($mgArgs.ContainsKey('CertificateThumbprint')) { 'certificate (thumbprint)' }
|
||||||
|
elseif($mgArgs.ContainsKey('AccessToken')) { 'BYO token' }
|
||||||
|
elseif($mgArgs.ContainsKey('Identity')) { 'managed identity' }
|
||||||
|
else { 'interactive (browser)' }
|
||||||
|
Write-Log "Calling Connect-MgGraph (mode: $authModeLog)..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Wipe any stale cached token from a prior session before the new auth.
|
||||||
|
[AuthenticationMgGraph]::ClearTokenCache()
|
||||||
|
|
||||||
|
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
|
||||||
|
Write-Log "Connect-MgGraph completed successfully"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "Connect-MgGraph failed" $_.Exception
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify Get-MgContext returns a session. If it does, we're signed in —
|
||||||
|
# even if we can't pull a raw bearer token out of the SDK. Invoke-MSGraphAPI
|
||||||
|
# has an SDK-routed fallback for that case (uses Invoke-MgGraphRequest, which
|
||||||
|
# the SDK auths internally with its own in-memory cache).
|
||||||
|
$ctx = $null
|
||||||
|
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||||
|
if(-not $ctx) {
|
||||||
|
Write-Log "Connect-MgGraph completed but Get-MgContext returned nothing. Treating as failed auth." 3
|
||||||
|
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { }
|
||||||
|
[AuthenticationMgGraph]::ClearTokenCache()
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$verifyToken = $this.GetAccessToken(0, "https://$($cloudEntry.GraphHost)")
|
||||||
|
if($verifyToken) {
|
||||||
|
Write-LogDebug "MgGraph session verified - token extracted ($($verifyToken.Length) chars)"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# SDK v2 keeps tokens opaque by design. Invoke-MSGraphAPI has a routed
|
||||||
|
# fallback that uses Invoke-MgGraphRequest (the SDK auths internally),
|
||||||
|
# so this is normal — debug-level only.
|
||||||
|
Write-LogDebug "MgGraph: session valid (Get-MgContext: tenant=$($ctx.TenantId)) but raw bearer not extractable. Graph calls route via Invoke-MgGraphRequest."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Realign the active auth provider so subsequent Invoke-MSGraphAPI calls route
|
||||||
|
# here. Symmetric to the same logic in MSAL's Add-MSALTokenInfo.
|
||||||
|
if(Get-Command Set-ActiveAuthProvider -ErrorAction SilentlyContinue) {
|
||||||
|
$cur = Get-AuthProvider
|
||||||
|
if($cur -and $cur.Id -ne $this.Id) {
|
||||||
|
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because MgGraph authentication succeeded"
|
||||||
|
Set-ActiveAuthProvider -Id $this.Id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Phase 3: persist per-tenant cloud memory. Prefer the Cloud arg the caller
|
||||||
|
# asked for; fall back to mapping Get-MgContext.Environment back to a Cloud
|
||||||
|
# value (the SDK's -Environment values match ours 1:1 via Clouds[].MgEnvironment).
|
||||||
|
# Declared before the try so it's always in scope for Register-AuthToken below.
|
||||||
|
$persistCloud = if($cloudValue) { $cloudValue } else { $null }
|
||||||
|
try {
|
||||||
|
if(-not $persistCloud -and $ctx -and $ctx.Environment) {
|
||||||
|
$match = $script:Clouds | Where-Object MgEnvironment -eq $ctx.Environment | Select-Object -First 1
|
||||||
|
if($match) { $persistCloud = $match.Value }
|
||||||
|
}
|
||||||
|
if(-not $persistCloud) { $persistCloud = Get-DefaultCloud }
|
||||||
|
if($persistCloud -and $ctx.TenantId) {
|
||||||
|
Save-TenantCloud -TenantId $ctx.TenantId -Cloud $persistCloud
|
||||||
|
Save-SettingStoreValue "" "LastLoggedOnCloud" $persistCloud
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogDebug "Phase 3 MgGraph cloud memory write failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register with the central token registry (single-session invariant: drop
|
||||||
|
# any prior entry first so repeated Connect never accumulates entries).
|
||||||
|
# The registry fires AuthenticatedNewToken with the canonical [IMAuthToken]
|
||||||
|
# - MgGraph now participates in the auth events for the first time.
|
||||||
|
if($this.CurrentTokenId -gt 0) {
|
||||||
|
Unregister-AuthToken -TokenId $this.CurrentTokenId
|
||||||
|
}
|
||||||
|
$this.CurrentTokenId = Get-NextAuthTokenId
|
||||||
|
return (Register-AuthToken -Provider $this -TokenId $this.CurrentTokenId -Cloud $persistCloud)
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Disconnect([int]$TokenId) {
|
||||||
|
try {
|
||||||
|
Disconnect-MgGraph -ErrorAction Stop | Out-Null
|
||||||
|
[AuthenticationMgGraph]::ClearTokenCache()
|
||||||
|
if($this.CurrentTokenId -gt 0) {
|
||||||
|
Unregister-AuthToken -TokenId $this.CurrentTokenId
|
||||||
|
$this.CurrentTokenId = 0
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "Disconnect-MgGraph failed" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-trigger Connect-MgGraph against the current session's tenant. With a valid
|
||||||
|
# refresh token in the cache the SDK does this silently; otherwise it prompts.
|
||||||
|
[bool] Refresh([int]$TokenId) {
|
||||||
|
try {
|
||||||
|
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||||
|
$mgArgs = @{ NoWelcome = $true }
|
||||||
|
if($ctx -and $ctx.TenantId) { $mgArgs['TenantId'] = $ctx.TenantId }
|
||||||
|
if($ctx -and $ctx.ClientId) { $mgArgs['ClientId'] = $ctx.ClientId }
|
||||||
|
if($ctx -and $ctx.Scopes) { $mgArgs['Scopes'] = @($ctx.Scopes) }
|
||||||
|
if($ctx -and $ctx.Environment -and $ctx.Environment -ne "Global") { $mgArgs['Environment'] = $ctx.Environment }
|
||||||
|
Write-Log "MgGraph Refresh: re-running Connect-MgGraph (tenant: $($ctx.TenantId))"
|
||||||
|
# Invalidate the cached bearer so the next GetAccessToken call re-extracts.
|
||||||
|
[AuthenticationMgGraph]::ClearTokenCache()
|
||||||
|
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "MgGraph Refresh failed" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static [void] ClearTokenCache() {
|
||||||
|
[AuthenticationMgGraph]::CachedToken = $null
|
||||||
|
[AuthenticationMgGraph]::CachedTokenExpiry = [DateTimeOffset]::MinValue
|
||||||
|
[AuthenticationMgGraph]::CachedTokenTenantId = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cached token + expiry to avoid hitting the SDK on every request.
|
||||||
|
static [string]$CachedToken
|
||||||
|
static [DateTimeOffset]$CachedTokenExpiry = [DateTimeOffset]::MinValue
|
||||||
|
static [string]$CachedTokenTenantId
|
||||||
|
|
||||||
|
# The single global token id for this provider's one live session. MgGraph is
|
||||||
|
# single-session by SDK design (one ambient Get-MgContext), so it holds exactly
|
||||||
|
# one registry entry at a time. 0 = not registered.
|
||||||
|
[int]$CurrentTokenId = 0
|
||||||
|
|
||||||
|
# Robust token extraction. Microsoft.Graph SDK v2 doesn't expose an access token
|
||||||
|
# accessor — AuthContext.AccessToken stays null in the delegated flow. We use the
|
||||||
|
# SDK's own HttpClient (which has its auth DelegatingHandler attached) to make a
|
||||||
|
# cheap HEAD-style request; the handler mutates the request to add
|
||||||
|
# "Authorization: Bearer <token>" before sending. We then read the header off the
|
||||||
|
# request. Same mechanism the SDK itself uses internally on every Mg* cmdlet —
|
||||||
|
# no reflection, no private APIs.
|
||||||
|
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||||
|
try {
|
||||||
|
# Return cached token if still valid (>5 min until expiry). The SDK refreshes
|
||||||
|
# internally on every call, so caching at our layer avoids per-request
|
||||||
|
# network round-trips just to "pull" a token.
|
||||||
|
$ctxTenantId = $null
|
||||||
|
try { $ctxTenantId = (Get-MgContext -ErrorAction SilentlyContinue).TenantId } catch { }
|
||||||
|
|
||||||
|
if([AuthenticationMgGraph]::CachedToken -and
|
||||||
|
[AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::UtcNow.AddMinutes(5) -and
|
||||||
|
[AuthenticationMgGraph]::CachedTokenTenantId -eq $ctxTenantId) {
|
||||||
|
return [AuthenticationMgGraph]::CachedToken
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionType = "Microsoft.Graph.PowerShell.Authentication.GraphSession" -as [type]
|
||||||
|
if(-not $sessionType) {
|
||||||
|
Write-LogDebug "MgGraph: GraphSession type not available; SDK not loaded?"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$session = $sessionType::Instance
|
||||||
|
if(-not $session) {
|
||||||
|
Write-LogDebug "MgGraph: GraphSession.Instance is null"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# The SDK exposes GraphHttpClient: an HttpClient wired with auth handlers.
|
||||||
|
$httpClient = $session.GraphHttpClient
|
||||||
|
if(-not $httpClient) {
|
||||||
|
Write-LogDebug "MgGraph: GraphHttpClient is null (Connect-MgGraph not run?)"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strategy A — direct AuthContext read (works on some SDK builds, fast-path).
|
||||||
|
if($session.AuthContext -and $session.AuthContext.AccessToken) {
|
||||||
|
$tok = [AuthenticationMgGraph]::UnprotectString($session.AuthContext.AccessToken)
|
||||||
|
if($tok) {
|
||||||
|
[AuthenticationMgGraph]::SaveTokenCache($tok, $ctxTenantId)
|
||||||
|
Write-LogDebug "MgGraph: token from AuthContext.AccessToken (fast-path)"
|
||||||
|
return $tok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strategy B — HttpClient sniff: make a trivial GET via the SDK's HttpClient,
|
||||||
|
# then read the Authorization header that the DelegatingHandler attached.
|
||||||
|
# This is the supported public contract of the SDK's auth pipeline.
|
||||||
|
$graphResource = if($Resource) { $Resource.TrimEnd('/') } else { "https://$(Get-GraphDomain)" }
|
||||||
|
$req = [System.Net.Http.HttpRequestMessage]::new(
|
||||||
|
[System.Net.Http.HttpMethod]::Get,
|
||||||
|
"$graphResource/v1.0/`$metadata")
|
||||||
|
try {
|
||||||
|
$task = $httpClient.SendAsync(
|
||||||
|
$req,
|
||||||
|
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead)
|
||||||
|
# 30s timeout — interactive auth could be required if cache is cold.
|
||||||
|
if(-not $task.Wait(30000)) {
|
||||||
|
Write-LogDebug "MgGraph: HttpClient sniff timed out"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
# Drop the response (we only care about the request headers the handler
|
||||||
|
# populated). Dispose to free the socket.
|
||||||
|
try { $task.Result.Dispose() } catch { }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogDebug "MgGraph: HttpClient sniff failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if($req.Headers.Authorization -and
|
||||||
|
$req.Headers.Authorization.Scheme -eq 'Bearer' -and
|
||||||
|
$req.Headers.Authorization.Parameter) {
|
||||||
|
$token = $req.Headers.Authorization.Parameter
|
||||||
|
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
|
||||||
|
Write-LogDebug "MgGraph: token via HttpClient auth-handler sniff"
|
||||||
|
return $token
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last resort — reflection probe.
|
||||||
|
$token = [AuthenticationMgGraph]::FindTokenViaReflection($session)
|
||||||
|
if($token) {
|
||||||
|
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
|
||||||
|
Write-LogDebug "MgGraph: token via reflection"
|
||||||
|
return $token
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctxJson = "<null>"
|
||||||
|
if($session.AuthContext) {
|
||||||
|
try {
|
||||||
|
$ctxJson = ($session.AuthContext | Select-Object TenantId, ClientId, AppName, AuthType, @{n='AccessTokenPresent';e={[bool]$_.AccessToken}}, @{n='Scopes';e={($_.Scopes -join ', ')}} | ConvertTo-Json -Compress)
|
||||||
|
}
|
||||||
|
catch { $ctxJson = "<unserializable>" }
|
||||||
|
}
|
||||||
|
Write-Log "MgGraph: could not extract access token. AuthContext=$ctxJson" 2
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "Failed to extract MgGraph access token" $_.Exception
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persist the freshly-acquired token + expiry. Expiry parsed from the JWT exp claim;
|
||||||
|
# fall back to "now + 50 minutes" if parsing fails (Entra tokens default to 60 min).
|
||||||
|
static [void] SaveTokenCache([string]$Token, [string]$TenantId) {
|
||||||
|
[AuthenticationMgGraph]::CachedToken = $Token
|
||||||
|
[AuthenticationMgGraph]::CachedTokenTenantId = $TenantId
|
||||||
|
$expiry = [DateTimeOffset]::UtcNow.AddMinutes(50)
|
||||||
|
try {
|
||||||
|
# Decode JWT exp claim
|
||||||
|
$jwt = Get-JWTtoken $Token
|
||||||
|
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
|
||||||
|
$expiry = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
[AuthenticationMgGraph]::CachedTokenExpiry = $expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unprotect a SecureString or pass a plain string through.
|
||||||
|
static [string] UnprotectString($Value) {
|
||||||
|
if($null -eq $Value) { return $null }
|
||||||
|
if($Value -is [SecureString]) {
|
||||||
|
return [System.Net.NetworkCredential]::new("", $Value).Password
|
||||||
|
}
|
||||||
|
return [string]$Value
|
||||||
|
}
|
||||||
|
|
||||||
|
# Walks the session object graph looking for a property/field whose name suggests it
|
||||||
|
# holds an access token. Limited to 2 levels deep to avoid infinite recursion.
|
||||||
|
static [string] FindTokenViaReflection($obj) {
|
||||||
|
if($null -eq $obj) { return $null }
|
||||||
|
try {
|
||||||
|
foreach($prop in $obj.PSObject.Properties) {
|
||||||
|
if($prop.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
|
||||||
|
$val = $prop.Value
|
||||||
|
if($val) {
|
||||||
|
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
|
||||||
|
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach($prop in $obj.PSObject.Properties) {
|
||||||
|
if($prop.Name -in 'AuthContext','InMemoryTokenCache','GraphOption','RequestContext') {
|
||||||
|
$child = $prop.Value
|
||||||
|
if($child) {
|
||||||
|
foreach($childProp in $child.PSObject.Properties) {
|
||||||
|
if($childProp.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
|
||||||
|
$val = $childProp.Value
|
||||||
|
if($val) {
|
||||||
|
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
|
||||||
|
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||||
|
# SDK does not expose the expiry to consumers. We return MaxValue and rely on
|
||||||
|
# MgGraph's internal silent refresh (it owns the cache).
|
||||||
|
return [datetime]::MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cached tenant display name to avoid hitting /organization on every refresh.
|
||||||
|
static [hashtable]$TenantNameCache = @{}
|
||||||
|
|
||||||
|
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||||
|
try {
|
||||||
|
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||||
|
if(-not $ctx) { return $null }
|
||||||
|
|
||||||
|
# SDK reports AuthType as Delegated / AppOnly. Map to our taxonomy.
|
||||||
|
$authType = switch ($ctx.AuthType) {
|
||||||
|
"AppOnly" { "ClientCredential" }
|
||||||
|
"Delegated" { "Interactive" }
|
||||||
|
default { "$($ctx.AuthType)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get-MgContext doesn't surface tenant display name. Fetch it once per
|
||||||
|
# tenant via Invoke-MgGraphRequest /organization (the SDK handles auth);
|
||||||
|
# cache for the rest of the session.
|
||||||
|
$tenantName = $null
|
||||||
|
if($ctx.TenantId) {
|
||||||
|
if([AuthenticationMgGraph]::TenantNameCache.ContainsKey($ctx.TenantId)) {
|
||||||
|
$tenantName = [AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
$org = Invoke-MgGraphRequest -Method GET -Uri "https://$(Get-GraphDomain)/v1.0/organization" -OutputType PSObject -ErrorAction Stop
|
||||||
|
if($org -and $org.value -and $org.value.Count -gt 0 -and $org.value[0].displayName) {
|
||||||
|
$tenantName = $org.value[0].displayName
|
||||||
|
[AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId] = $tenantName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogDebug "MgGraph GetUserInfo: /organization fetch failed ($($_.Exception.Message)); tenant display name will be unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expiry comes from the JWT exp claim we parsed into CachedTokenExpiry.
|
||||||
|
# If no token has been minted yet this session, trigger one — cheap when
|
||||||
|
# the SDK's in-memory cache is warm.
|
||||||
|
$expiresOn = $null
|
||||||
|
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
|
||||||
|
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
[void]$this.GetAccessToken(0, "https://$(Get-GraphDomain)")
|
||||||
|
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
|
||||||
|
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Provider = $this.Id
|
||||||
|
DisplayName = $ctx.Account
|
||||||
|
UPN = $ctx.Account
|
||||||
|
UserId = $null # Not surfaced by Get-MgContext
|
||||||
|
TenantId = $ctx.TenantId
|
||||||
|
TenantName = $tenantName
|
||||||
|
AppId = $ctx.ClientId
|
||||||
|
AppName = $ctx.AppName
|
||||||
|
AuthType = $authType
|
||||||
|
ExpiresOn = $expiresOn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetCachedAccounts() {
|
||||||
|
# Delegate to a module function — PS class method bodies are parsed strictly
|
||||||
|
# (type references like [Microsoft.Identity.Client.PublicClientApplicationBuilder]
|
||||||
|
# have to resolve at PARSE time, before MSAL DLLs are loaded). Module
|
||||||
|
# functions are late-bound and tolerate this.
|
||||||
|
$rows = @()
|
||||||
|
try {
|
||||||
|
$rows = @(Get-MgGraphCachedMsalAccounts -ProviderId $this.Id)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogDebug "MgGraph GetCachedAccounts failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return [PSCustomObject[]]$rows
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
||||||
|
# SDK does not surface available tenants. Tenant switching means
|
||||||
|
# Disconnect + Connect with -TenantId, which the UI can offer via a manual
|
||||||
|
# tenant id entry (Phase 3 UI concern).
|
||||||
|
return [PSCustomObject[]]@()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#ImportOrder 27
|
||||||
|
|
||||||
|
# Offline "mock tenant" provider.
|
||||||
|
#
|
||||||
|
# Signs in with no network and answers every Graph request from the JSON files
|
||||||
|
# under IM_MOCK_DATA (see Internal/MockGraph.ps1). Registered only when that
|
||||||
|
# variable points at a folder - a normal launch never sees it - and used for
|
||||||
|
# screenshots, demos and UI work without a tenant.
|
||||||
|
#
|
||||||
|
# The access token is a real-looking but unsigned JWT: the access-marking code
|
||||||
|
# reads scp / wids from it exactly as it would from Entra, so the menu renders
|
||||||
|
# with full access and no role lookup. Every request then goes through
|
||||||
|
# InvokeWebRequest (RoutesAllRequests) instead of HTTPS.
|
||||||
|
|
||||||
|
# What a mock 4xx throws. Invoke-MSGraphAPI reads the failure from
|
||||||
|
# $_.Exception.Response - StatusCode, Headers, and the body through
|
||||||
|
# GetResponseStream() - the same way it reads a WebException from
|
||||||
|
# Invoke-WebRequest, so a mock 404 reports its status, code and message like a
|
||||||
|
# real one. WebException.Response cannot be set from PowerShell, hence a class.
|
||||||
|
class MockGraphResponseException : System.Exception {
|
||||||
|
[object]$Response
|
||||||
|
|
||||||
|
MockGraphResponseException([string]$Message, [object]$Response) : base($Message) {
|
||||||
|
$this.Response = $Response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthenticationMock : AuthenticationProvider {
|
||||||
|
|
||||||
|
static [hashtable]$Tokens = @{}
|
||||||
|
|
||||||
|
[string]$DataFolder
|
||||||
|
|
||||||
|
AuthenticationMock() {
|
||||||
|
$this.Id = "Mock"
|
||||||
|
$this.DisplayName = "Mock tenant (offline demo data)"
|
||||||
|
|
||||||
|
$this.SupportsInteractive = $true
|
||||||
|
$this.SupportsClientSecret = $false
|
||||||
|
$this.SupportsCertificate = $false
|
||||||
|
$this.SupportsIdentityProvider = $false
|
||||||
|
$this.SupportsBYOToken = $false
|
||||||
|
$this.SupportsClaimsChallenge = $false
|
||||||
|
$this.SupportsMultiTenant = $false
|
||||||
|
$this.SupportsRefresh = $true
|
||||||
|
$this.SupportsForget = $false
|
||||||
|
$this.SupportsCachedUsers = $false
|
||||||
|
$this.RoutesAllRequests = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] Initialize() {
|
||||||
|
$this.DataFolder = [string]$env:IM_MOCK_DATA
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sign in at startup - there is nothing to prompt for.
|
||||||
|
[bool] TryResumeSession() {
|
||||||
|
if(-not $this.DataFolder) { return $false }
|
||||||
|
$token = $this.Connect(@{})
|
||||||
|
return [bool]$token
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||||
|
if(-not $this.DataFolder -or -not (Test-Path -LiteralPath $this.DataFolder -PathType Container)) {
|
||||||
|
Write-Log "Mock provider: IM_MOCK_DATA does not point at a folder ('$($this.DataFolder)')" 3
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Get-MockTenantProfile -Root $this.DataFolder
|
||||||
|
Initialize-MockGraphStore -Root $this.DataFolder | Out-Null
|
||||||
|
|
||||||
|
$tokenId = Get-NextAuthTokenId
|
||||||
|
[AuthenticationMock]::Tokens[$tokenId] = @{
|
||||||
|
Id = $tokenId
|
||||||
|
Tenant = $tenant
|
||||||
|
AccessToken = (New-MockAccessToken -Tenant $tenant)
|
||||||
|
AcquiredAt = [DateTime]::UtcNow
|
||||||
|
}
|
||||||
|
Write-Log "Mock provider: signed in to '$($tenant.TenantName)' as $($tenant.UPN) (TokenId=$tokenId)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cur = Get-AuthProvider
|
||||||
|
if($cur -and $cur.Id -ne $this.Id) { Set-ActiveAuthProvider -Id $this.Id }
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return (Register-AuthToken -Provider $this -TokenId $tokenId -Cloud (Get-DefaultCloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Disconnect([int]$TokenId) {
|
||||||
|
if(-not [AuthenticationMock]::Tokens.ContainsKey($TokenId)) { return $false }
|
||||||
|
Unregister-AuthToken -TokenId $TokenId
|
||||||
|
[AuthenticationMock]::Tokens.Remove($TokenId) | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Refresh([int]$TokenId) {
|
||||||
|
return [AuthenticationMock]::Tokens.ContainsKey($TokenId)
|
||||||
|
}
|
||||||
|
|
||||||
|
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||||
|
$entry = $this.GetEntry($TokenId)
|
||||||
|
if(-not $entry) { return $null }
|
||||||
|
return [string]$entry.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||||
|
return [datetime]::MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
|
||||||
|
return (Invoke-MockGraphRequest -Url $Url -Method $Method -Body $Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||||
|
$entry = $this.GetEntry($TokenId)
|
||||||
|
if(-not $entry) { return $null }
|
||||||
|
$tenant = $entry.Tenant
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Provider = $this.Id
|
||||||
|
DisplayName = $tenant.DisplayName
|
||||||
|
UPN = $tenant.UPN
|
||||||
|
UserId = $tenant.UserId
|
||||||
|
TenantId = $tenant.TenantId
|
||||||
|
TenantName = $tenant.TenantName
|
||||||
|
AppId = $tenant.AppId
|
||||||
|
AppName = $tenant.AppName
|
||||||
|
AuthType = "Interactive"
|
||||||
|
ExpiresOn = [DateTime]::Now.AddYears(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetSessionInfoRows() {
|
||||||
|
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
$rows.Add([PSCustomObject]@{ Name = "Provider"; Value = "Mock (offline)" })
|
||||||
|
$rows.Add([PSCustomObject]@{ Name = "Data folder"; Value = $this.DataFolder })
|
||||||
|
return $rows.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden [hashtable] GetEntry([int]$TokenId) {
|
||||||
|
if($TokenId -le 0 -and [AuthenticationMock]::Tokens.Count -gt 0) {
|
||||||
|
$TokenId = ([AuthenticationMock]::Tokens.Keys | Sort-Object -Descending | Select-Object -First 1)
|
||||||
|
}
|
||||||
|
if(-not [AuthenticationMock]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||||
|
return [AuthenticationMock]::Tokens[$TokenId]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,949 @@
|
|||||||
|
#ImportOrder 27
|
||||||
|
|
||||||
|
# Pure-PowerShell implementation of AuthenticationProvider.
|
||||||
|
#
|
||||||
|
# No MSAL.NET DLL, no Microsoft.Graph.Authentication SDK. The class talks to
|
||||||
|
# https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token directly via
|
||||||
|
# Invoke-RestMethod. Designed for automation: scheduled tasks, CI runners,
|
||||||
|
# AKS workload identity, Azure VMs, App Service / Functions.
|
||||||
|
#
|
||||||
|
# Supported flows (dispatched by Connect() based on which arg is set, in
|
||||||
|
# this priority order):
|
||||||
|
#
|
||||||
|
# Token BYO bearer (no flow)
|
||||||
|
# DeviceCode device code grant (RFC 8628) — v2
|
||||||
|
# ManagedIdentity (no Federated*) IMDS / App Service / Functions
|
||||||
|
# FederatedTokenFile / FederatedToken Workload Identity Federation
|
||||||
|
# Certificate / CertificatePath client_credentials w/ private_key_jwt
|
||||||
|
# Secret client_credentials w/ shared secret
|
||||||
|
# Credential (PSCredential) ROPC (grant_type=password)
|
||||||
|
#
|
||||||
|
# All endpoint heavy lifting (HTTP, JWT signing, IMDS detection, error
|
||||||
|
# surfacing) lives in Internal/AuthenticationOAuthHelpers.ps1 — module
|
||||||
|
# functions are late-bound and tolerate types that don't resolve at parse
|
||||||
|
# time, which keeps this class file portable.
|
||||||
|
#
|
||||||
|
# Per-tenant cloud memory is stamped via Save-TenantCloud at the end of every
|
||||||
|
# successful Connect, matching what AuthenticationMSAL and AuthenticationMgGraph
|
||||||
|
# do. Same for AppEvents (AuthenticatedNewToken / AuthenticationTokenRefresh /
|
||||||
|
# AuthenticationUserDisconnected / AuthenticationFailed).
|
||||||
|
|
||||||
|
class AuthenticationOAuth : AuthenticationProvider {
|
||||||
|
|
||||||
|
# Token cache. Static so every reference to the provider sees the same
|
||||||
|
# store within a session. Keys are integer TokenIds (consistent with the
|
||||||
|
# other providers).
|
||||||
|
static [hashtable]$Tokens = @{}
|
||||||
|
static [int]$NextTokenId = 1
|
||||||
|
|
||||||
|
# Cached tenant display name per TenantId so GetUserInfo doesn't hit
|
||||||
|
# /organization on every refresh event. Failed lookups (e.g. app-only token
|
||||||
|
# without Organization.Read.All) cache $null so they aren't retried either.
|
||||||
|
static [hashtable]$TenantNameCache = @{}
|
||||||
|
|
||||||
|
AuthenticationOAuth() {
|
||||||
|
$this.Id = "OAuth"
|
||||||
|
$this.DisplayName = "Direct OAuth (no SDK)"
|
||||||
|
|
||||||
|
# Required contract.
|
||||||
|
$this.SupportsInteractive = $true # device code flow (RFC 8628)
|
||||||
|
$this.SupportsClientSecret = $true
|
||||||
|
$this.SupportsCertificate = $true
|
||||||
|
|
||||||
|
# Recommended.
|
||||||
|
$this.SupportsIdentityProvider = $true # IMDS + workload federation
|
||||||
|
$this.SupportsBYOToken = $true
|
||||||
|
$this.SupportsClaimsChallenge = $true # re-mints via the /token body (see GetClaimsToken)
|
||||||
|
|
||||||
|
$this.SupportsMultiTenant = $true
|
||||||
|
$this.SupportsRefresh = $true
|
||||||
|
$this.SupportsForget = $true
|
||||||
|
$this.SupportsCachedUsers = $false # No persistent on-disk cache
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] Initialize() {
|
||||||
|
# Register the OAuth settings section (browser-login config). Guard on the
|
||||||
|
# settings API being loaded (module-load order); Initialize() runs from
|
||||||
|
# Register-AuthProvider, before the Settings dialog renders.
|
||||||
|
if(-not (Get-Command -Name Add-SettingsSection -ErrorAction SilentlyContinue)) { return }
|
||||||
|
if(-not (Get-Command -Name Add-SettingsObject -ErrorAction SilentlyContinue)) { return }
|
||||||
|
try {
|
||||||
|
# Order 9 = directly under the MSAL section (8). App (client) id and tenant id
|
||||||
|
# are NOT registered here - MSAL and OAuth are two ways of authenticating with
|
||||||
|
# the SAME app, so both resolve it from the common Entra settings in the
|
||||||
|
# Authentication section (Get-EntraApp: dropdown -> custom app id -> default
|
||||||
|
# Microsoft Graph PowerShell public client).
|
||||||
|
Add-SettingsSection -Title "OAuth" -Id "OAuth" -Order 9
|
||||||
|
|
||||||
|
Add-SettingsObject -Title "OAuth browser prompt" -Key "OAuthPrompt" -Type "List" -DefaultValue "select_account" `
|
||||||
|
-ItemsSource @(
|
||||||
|
[PSCustomObject]@{ Name = "Select account"; Value = "select_account" },
|
||||||
|
[PSCustomObject]@{ Name = "Force login"; Value = "login" },
|
||||||
|
[PSCustomObject]@{ Name = "Consent"; Value = "consent" },
|
||||||
|
[PSCustomObject]@{ Name = "None (silent)"; Value = "none" }
|
||||||
|
) `
|
||||||
|
-Description "OAuth /authorize prompt behaviour. 'Force login' re-authenticates even with an active browser session (equivalent to force-interactive); 'None' fails if interaction would be required." `
|
||||||
|
-Section "OAuth"
|
||||||
|
|
||||||
|
Add-SettingsObject -Title "OAuth login hint (UPN)" -Key "OAuthLoginHint" -Type "String" -DefaultValue "" `
|
||||||
|
-Description "Optional UPN to pre-fill on the sign-in page (login_hint)." `
|
||||||
|
-Section "OAuth"
|
||||||
|
|
||||||
|
Add-SettingsObject -Title "OAuth redirect port" -Key "OAuthRedirectPort" -Type "Int" -DefaultValue 0 `
|
||||||
|
-Description "Fixed loopback port for the browser redirect (http://localhost:<port>). 0 = pick a free port automatically. Set a fixed port only if your app registration requires a specific http://localhost:<port> redirect." `
|
||||||
|
-Section "OAuth"
|
||||||
|
|
||||||
|
Add-SettingsObject -Title "Remember login (cache token)" -Key "OAuthCacheToken" -Type "Boolean" -DefaultValue $false `
|
||||||
|
-Description "Persist the OAuth refresh token (DPAPI-encrypted, current user) so the app silently resumes the browser session after a restart. When off, you sign in again after each restart (usually a quick browser redirect via existing SSO)." `
|
||||||
|
-Section "OAuth"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "Failed to register OAuth settings section" $_.Exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Silent cross-restart resume for the browser flow. When "Remember login" is on and
|
||||||
|
# a DPAPI-cached refresh token exists, mint a fresh token via the refresh_token grant
|
||||||
|
# (no browser) and register it as the default. Returns $true on success. Base is a
|
||||||
|
# no-op; MSAL/other providers have their own resume paths.
|
||||||
|
[bool] TryResumeSession() {
|
||||||
|
try {
|
||||||
|
if((Get-SettingValue "OAuthCacheToken") -ne $true) { return $false }
|
||||||
|
$cache = Read-OAuthTokenCache
|
||||||
|
if(-not $cache -or -not $cache.RefreshToken) { return $false }
|
||||||
|
|
||||||
|
$cloudValue = if($cache.Cloud) { [string]$cache.Cloud } else { Get-DefaultCloud }
|
||||||
|
$authority = if($cache.Authority) { [string]$cache.Authority } else { (Get-CloudByValue $cloudValue).AADAuthority }
|
||||||
|
$resource = if($cache.Resource) { [string]$cache.Resource } else { "https://$((Get-CloudByValue $cloudValue).GraphHost)" }
|
||||||
|
$tenant = if($cache.TenantId) { [string]$cache.TenantId } else { 'organizations' }
|
||||||
|
$scope = "$resource/.default offline_access"
|
||||||
|
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenant -Body @{
|
||||||
|
grant_type = 'refresh_token'
|
||||||
|
client_id = $cache.ClientId
|
||||||
|
refresh_token = $cache.RefreshToken
|
||||||
|
scope = $scope
|
||||||
|
}
|
||||||
|
if(-not $tokenResp -or -not $tokenResp.access_token) { return $false }
|
||||||
|
|
||||||
|
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
||||||
|
if($tokenResp.expires_in) { $expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in) }
|
||||||
|
|
||||||
|
# Recover the real tenant from the token when we resumed under 'organizations'.
|
||||||
|
$tenantId = [string]$cache.TenantId
|
||||||
|
try {
|
||||||
|
$jwt = Get-JWTtoken $tokenResp.access_token
|
||||||
|
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) { $tenantId = [string]$jwt.Payload.tid }
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = 'AuthCode'
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $cache.ClientId
|
||||||
|
}
|
||||||
|
$tokenId = Get-NextAuthTokenId
|
||||||
|
$entry = [ordered]@{
|
||||||
|
Id = $tokenId
|
||||||
|
AccessToken = [string]$tokenResp.access_token
|
||||||
|
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { [string]$cache.RefreshToken }
|
||||||
|
ExpiresAt = $expiresAt
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $cache.ClientId
|
||||||
|
AuthMethod = 'AuthCode'
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Resource = $resource
|
||||||
|
CredentialState = $cred
|
||||||
|
AcquiredAt = [DateTime]::UtcNow
|
||||||
|
}
|
||||||
|
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
|
||||||
|
|
||||||
|
# A rotated refresh token must overwrite the cached one.
|
||||||
|
if($tokenResp.refresh_token) {
|
||||||
|
[void](Save-OAuthTokenCache -Data @{
|
||||||
|
RefreshToken = [string]$tokenResp.refresh_token
|
||||||
|
ClientId = $cache.ClientId
|
||||||
|
TenantId = $tenantId
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
AuthMethod = 'AuthCode'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
[void](Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue -Default)
|
||||||
|
Write-Log "OAuth provider: resumed cached browser session (TokenId=$tokenId, tenant=$tenantId)"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "OAuth provider: TryResumeSession failed" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden [string] ResolvePublicClientId([string]$AppId) {
|
||||||
|
if(-not [String]::IsNullOrWhiteSpace($AppId)) { return $AppId }
|
||||||
|
|
||||||
|
# Match MSAL's app selection: Settings -> Entra app dropdown, then custom
|
||||||
|
# app id, then the default Microsoft Graph PowerShell public client.
|
||||||
|
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
|
||||||
|
try {
|
||||||
|
$entraApp = Get-EntraApp
|
||||||
|
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.ClientId)) {
|
||||||
|
return [string]$entraApp.ClientId
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return "14d82eec-204b-4c2f-b7e8-296a70dab67e"
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden [string] ResolveTenantId([string]$TenantId) {
|
||||||
|
if(-not [String]::IsNullOrWhiteSpace($TenantId)) { return $TenantId }
|
||||||
|
|
||||||
|
# Shared identity config: the common Entra settings supply the tenant the same
|
||||||
|
# way they supply the app id. Get-EntraApp surfaces EntraCustomTenantId on
|
||||||
|
# custom-app rows; the built-in app rows have no TenantId property, which
|
||||||
|
# safely yields $null here.
|
||||||
|
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
|
||||||
|
try {
|
||||||
|
$entraApp = Get-EntraApp
|
||||||
|
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.TenantId)) {
|
||||||
|
return [string]$entraApp.TenantId
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 'organizations' = any work/school account; the real tenant id is recovered
|
||||||
|
# from the token's tid claim after sign-in.
|
||||||
|
return 'organizations'
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Auth lifecycle ===
|
||||||
|
|
||||||
|
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||||
|
Write-Log "AuthenticationOAuth.Connect starting"
|
||||||
|
|
||||||
|
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud } else { Get-DefaultCloud }
|
||||||
|
$cloudEntry = Get-CloudByValue $cloudValue
|
||||||
|
$authority = $cloudEntry.AADAuthority
|
||||||
|
$graphHost = $cloudEntry.GraphHost
|
||||||
|
$defaultScope = "https://$graphHost/.default"
|
||||||
|
$resource = "https://$graphHost"
|
||||||
|
|
||||||
|
$tenantId = [string]$Arguments.TenantId
|
||||||
|
$clientId = [string]$Arguments.AppId
|
||||||
|
|
||||||
|
# Determine flow + run it. State stored in $cred is what Refresh() and
|
||||||
|
# GetAccessToken() use to re-acquire when the access token expires.
|
||||||
|
$cred = $null
|
||||||
|
$tokenResp = $null
|
||||||
|
$authMethod = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if($Arguments.ContainsKey('Token') -and $Arguments.Token) {
|
||||||
|
$authMethod = 'BYO'
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
}
|
||||||
|
$tokenResp = [PSCustomObject]@{
|
||||||
|
access_token = [string]$Arguments.Token
|
||||||
|
expires_in = $null # unknown; parsed from JWT exp below
|
||||||
|
token_type = 'Bearer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif( ($Arguments.ContainsKey('Browser') -and $Arguments.Browser) -or
|
||||||
|
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive -and
|
||||||
|
-not ($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -and
|
||||||
|
((Get-CacheObject "ShowUI") -eq $true)) ) {
|
||||||
|
# Browser (Authorization Code + PKCE, loopback redirect). Chosen for an
|
||||||
|
# explicit -Browser, or for -Interactive when a GUI is present (ShowUI);
|
||||||
|
# headless -Interactive keeps going to device code below.
|
||||||
|
$authMethod = 'AuthCode'
|
||||||
|
# Client id / tenant: explicit args win, else the common Entra settings
|
||||||
|
# (same app selection as MSAL - dropdown, custom app id, then the
|
||||||
|
# well-known Microsoft Graph PowerShell public client, which already
|
||||||
|
# has http://localhost registered).
|
||||||
|
$clientId = $this.ResolvePublicClientId($clientId)
|
||||||
|
$acTenant = $this.ResolveTenantId($tenantId)
|
||||||
|
$prompt = if($Arguments.Prompt) { [string]$Arguments.Prompt } else { [string](Get-SettingValue "OAuthPrompt") }
|
||||||
|
$loginHint = if($Arguments.LoginHint) { [string]$Arguments.LoginHint } else { [string](Get-SettingValue "OAuthLoginHint") }
|
||||||
|
$redirectPort = if($Arguments.RedirectPort) { [int]$Arguments.RedirectPort } else { [int](Get-SettingValue "OAuthRedirectPort") }
|
||||||
|
$timeoutSec = 600
|
||||||
|
try { $t = [int](Get-SettingValue "MSGraphInteractiveTimeoutSec"); if($t -gt 0) { $timeoutSec = $t } } catch { }
|
||||||
|
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$tokenResp = Invoke-OAuthAuthCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId `
|
||||||
|
-Scope "$defaultScope offline_access" -RedirectPort $redirectPort -Prompt $prompt -LoginHint $loginHint -TimeoutSec $timeoutSec
|
||||||
|
}
|
||||||
|
catch [System.OperationCanceledException] {
|
||||||
|
# The user clicked Cancel on the sign-in overlay. That is a decision,
|
||||||
|
# not a broken browser, so do not start a second (device code) login
|
||||||
|
# behind their back - let it out and leave the session signed out.
|
||||||
|
Write-Log "OAuth provider: browser sign-in cancelled by the user" 2
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Browser unavailable (headless -Browser, no default browser, or the
|
||||||
|
# loopback port is blocked). Fall back to device code so sign-in can
|
||||||
|
# still complete. Refresh works identically for both (refresh_token).
|
||||||
|
Write-Log "OAuth provider: browser sign-in failed ($($_.Exception.Message)); falling back to device code" 2
|
||||||
|
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId -Scope "$defaultScope offline_access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif(($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -or
|
||||||
|
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive)) {
|
||||||
|
if($Arguments.Interactive -and -not $Arguments.DeviceCode) {
|
||||||
|
# -Interactive reached here means no GUI was available for the
|
||||||
|
# browser flow above (headless), so use RFC 8628 device code -
|
||||||
|
# keeping the Connect-IntuneManagement -Interactive story working
|
||||||
|
# on every provider.
|
||||||
|
Write-Log "OAuth provider: -Interactive routed to device code flow (no GUI for browser login)"
|
||||||
|
}
|
||||||
|
$authMethod = 'DeviceCode'
|
||||||
|
$clientId = $this.ResolvePublicClientId($clientId)
|
||||||
|
if(-not $clientId) { throw "AppId is required for device code auth" }
|
||||||
|
# Tenant from the common Entra settings when not explicit (same
|
||||||
|
# resolution as the browser flow above).
|
||||||
|
$dcTenant = $this.ResolveTenantId($tenantId)
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
}
|
||||||
|
# offline_access so the response includes a refresh_token —
|
||||||
|
# that's what AcquireFromState uses for silent renewal.
|
||||||
|
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $dcTenant -ClientId $clientId -Scope "$defaultScope offline_access"
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity -and
|
||||||
|
-not ($Arguments.FederatedTokenFile -or $Arguments.FederatedToken)) {
|
||||||
|
$authMethod = 'IMDS'
|
||||||
|
if(-not $clientId -and $Arguments.ManagedIdentityClientId) {
|
||||||
|
$clientId = [string]$Arguments.ManagedIdentityClientId
|
||||||
|
}
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
}
|
||||||
|
$tokenResp = Invoke-OAuthIMDS -Resource $resource -ClientId $clientId
|
||||||
|
# IMDS responds with token_type / access_token / expires_in (string seconds);
|
||||||
|
# tenant_id is also included. Use the IMDS-reported tenant if our caller
|
||||||
|
# didn't supply one.
|
||||||
|
if(-not $tenantId -and $tokenResp.tenant_id) {
|
||||||
|
$tenantId = $tokenResp.tenant_id
|
||||||
|
$cred.TenantId = $tenantId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif($Arguments.FederatedTokenFile -or $Arguments.FederatedToken) {
|
||||||
|
$authMethod = 'Federated'
|
||||||
|
if(-not $tenantId) { throw "TenantId is required for federated credential auth" }
|
||||||
|
if(-not $clientId) { throw "AppId is required for federated credential auth" }
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
FederatedTokenFile = [string]$Arguments.FederatedTokenFile
|
||||||
|
FederatedToken = [string]$Arguments.FederatedToken
|
||||||
|
}
|
||||||
|
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||||
|
grant_type = 'client_credentials'
|
||||||
|
client_id = $clientId
|
||||||
|
scope = $defaultScope
|
||||||
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||||
|
client_assertion = $assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
|
||||||
|
$authMethod = 'Certificate'
|
||||||
|
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
|
||||||
|
if(-not $clientId) { throw "AppId is required for certificate auth" }
|
||||||
|
$cert = $this.ResolveCertificate($Arguments.Certificate, $null, $null)
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
Certificate = $cert
|
||||||
|
}
|
||||||
|
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||||
|
grant_type = 'client_credentials'
|
||||||
|
client_id = $clientId
|
||||||
|
scope = $defaultScope
|
||||||
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||||
|
client_assertion = $assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
|
||||||
|
$authMethod = 'Certificate'
|
||||||
|
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
|
||||||
|
if(-not $clientId) { throw "AppId is required for certificate auth" }
|
||||||
|
$cert = $this.ResolveCertificate($null, [string]$Arguments.CertificatePath, $Arguments.CertificatePassword)
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
Certificate = $cert
|
||||||
|
CertificatePath = [string]$Arguments.CertificatePath
|
||||||
|
CertificatePassword = $Arguments.CertificatePassword
|
||||||
|
}
|
||||||
|
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||||
|
grant_type = 'client_credentials'
|
||||||
|
client_id = $clientId
|
||||||
|
scope = $defaultScope
|
||||||
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||||
|
client_assertion = $assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
|
||||||
|
$authMethod = 'ClientSecret'
|
||||||
|
if(-not $tenantId) { throw "TenantId is required for client-secret auth" }
|
||||||
|
if(-not $clientId) { throw "AppId is required for client-secret auth" }
|
||||||
|
$secretPlain = if($Arguments.Secret -is [SecureString]) {
|
||||||
|
[System.Net.NetworkCredential]::new("", $Arguments.Secret).Password
|
||||||
|
} else {
|
||||||
|
[string]$Arguments.Secret
|
||||||
|
}
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
Secret = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret } else { ConvertTo-SecureString $secretPlain -AsPlainText -Force }
|
||||||
|
}
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||||
|
grant_type = 'client_credentials'
|
||||||
|
client_id = $clientId
|
||||||
|
client_secret = $secretPlain
|
||||||
|
scope = $defaultScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif($Arguments.ContainsKey('Credential') -and $Arguments.Credential) {
|
||||||
|
$authMethod = 'Password'
|
||||||
|
if(-not $tenantId) { throw "TenantId is required for password auth" }
|
||||||
|
if(-not $clientId) { throw "AppId is required for password auth" }
|
||||||
|
$pscred = [PSCredential]$Arguments.Credential
|
||||||
|
$passwordPlain = $pscred.GetNetworkCredential().Password
|
||||||
|
$cred = [ordered]@{
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
Username = $pscred.UserName
|
||||||
|
Password = $pscred.Password
|
||||||
|
}
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
||||||
|
grant_type = 'password'
|
||||||
|
client_id = $clientId
|
||||||
|
username = $pscred.UserName
|
||||||
|
password = $passwordPlain
|
||||||
|
scope = "$defaultScope offline_access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw "AuthenticationOAuth.Connect: no supported credential argument was provided. Pass -Secret, -Certificate, -CertificatePath, -ManagedIdentity, -FederatedTokenFile/-FederatedToken, -Credential, -DeviceCode, or -Token."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch [System.OperationCanceledException] {
|
||||||
|
# An interactive user explicitly abandoned the flow. Do not publish the
|
||||||
|
# canonical AuthenticationFailed event: consumers use that event for real
|
||||||
|
# authentication faults (and may tear down/redraw an existing session).
|
||||||
|
Write-Log "OAuth provider Connect cancelled by the user (method: $authMethod)" 2
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "OAuth provider Connect failed (method: $authMethod)" $_.Exception
|
||||||
|
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth Connect failed (method: $authMethod)" -Exception $_.Exception
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if(-not $tokenResp -or -not $tokenResp.access_token) {
|
||||||
|
Write-Log "OAuth provider: token request returned no access_token (method: $authMethod)" 3
|
||||||
|
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth token request returned no access_token (method: $authMethod)"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compute expiry. Prefer expires_in (relative seconds; reliable across all
|
||||||
|
# flows). Fall back to JWT exp claim if expires_in is absent (BYO token).
|
||||||
|
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
||||||
|
if($tokenResp.expires_in) {
|
||||||
|
$secs = [int]$tokenResp.expires_in
|
||||||
|
$expiresAt = [DateTime]::UtcNow.AddSeconds($secs)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
$jwt = Get-JWTtoken $tokenResp.access_token
|
||||||
|
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
|
||||||
|
$expiresAt = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp).UtcDateTime
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
# If TenantId wasn't supplied (BYO token, IMDS without tenant_id),
|
||||||
|
# extract it from the access_token's `tid` claim so multi-tenant
|
||||||
|
# routing (Get-GraphDomain, Save-TenantCloud) still works.
|
||||||
|
if(-not $tenantId) {
|
||||||
|
try {
|
||||||
|
$jwt = Get-JWTtoken $tokenResp.access_token
|
||||||
|
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) {
|
||||||
|
$tenantId = [string]$jwt.Payload.tid
|
||||||
|
$cred.TenantId = $tenantId
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allocate a TokenId from the central registry so ids are globally unique
|
||||||
|
# across providers (MSAL/OAuth/MgGraph), not just within this provider.
|
||||||
|
$tokenId = Get-NextAuthTokenId
|
||||||
|
$entry = [ordered]@{
|
||||||
|
Id = $tokenId
|
||||||
|
AccessToken = [string]$tokenResp.access_token
|
||||||
|
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { $null }
|
||||||
|
ExpiresAt = $expiresAt
|
||||||
|
TenantId = $tenantId
|
||||||
|
ClientId = $clientId
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Resource = $resource
|
||||||
|
CredentialState = $cred
|
||||||
|
AcquiredAt = [DateTime]::UtcNow
|
||||||
|
}
|
||||||
|
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
|
||||||
|
|
||||||
|
# Opt-in cross-restart persistence for the browser flow: DPAPI-encrypt the
|
||||||
|
# refresh token when "Remember login" (OAuthCacheToken) is on. Only for the
|
||||||
|
# interactive browser method - service/BYO flows re-acquire from their own
|
||||||
|
# credentials and shouldn't leave a refresh token on disk.
|
||||||
|
if($authMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
|
||||||
|
[void](Save-OAuthTokenCache -Data @{
|
||||||
|
RefreshToken = $entry.RefreshToken
|
||||||
|
ClientId = $clientId
|
||||||
|
TenantId = $tenantId
|
||||||
|
Cloud = $cloudValue
|
||||||
|
Authority = $authority
|
||||||
|
Resource = $resource
|
||||||
|
AuthMethod = $authMethod
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log "OAuth provider: acquired token (TokenId=$tokenId, method=$authMethod, tenant=$tenantId, expires=$($expiresAt.ToLocalTime().ToString('s')))"
|
||||||
|
|
||||||
|
# Persist per-tenant cloud memory so subsequent calls land on the same authority.
|
||||||
|
try {
|
||||||
|
if($tenantId) {
|
||||||
|
Save-TenantCloud -TenantId $tenantId -Cloud $cloudValue
|
||||||
|
Save-SettingStoreValue "" "LastLoggedOnCloud" $cloudValue
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-LogDebug "OAuth provider: Save-TenantCloud failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Realign the active provider so subsequent Invoke-MSGraphAPI calls route here.
|
||||||
|
try {
|
||||||
|
$cur = Get-AuthProvider
|
||||||
|
if($cur -and $cur.Id -ne $this.Id) {
|
||||||
|
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because OAuth authentication succeeded"
|
||||||
|
Set-ActiveAuthProvider -Id $this.Id
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
# Register with the central token registry, which fires AuthenticatedNewToken
|
||||||
|
# with the canonical [IMAuthToken] payload (no longer fired directly here).
|
||||||
|
$token = Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue
|
||||||
|
return $token
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Disconnect([int]$TokenId) {
|
||||||
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
|
||||||
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||||
|
Write-Log "OAuth provider: disconnected TokenId=$TokenId (tenant=$($entry.TenantId))"
|
||||||
|
# Unregister BEFORE dropping the backend entry so the disconnect snapshot can
|
||||||
|
# still hydrate via GetUserInfo. The registry fires AuthenticationUserDisconnected
|
||||||
|
# (and promotes a survivor default if needed).
|
||||||
|
Unregister-AuthToken -TokenId $TokenId
|
||||||
|
[AuthenticationOAuth]::Tokens.Remove($TokenId) | Out-Null
|
||||||
|
# Sign-out of a browser session also drops the persisted refresh token so a
|
||||||
|
# later launch doesn't silently resume the account the user just signed out of.
|
||||||
|
if($entry.AuthMethod -eq 'AuthCode') { Clear-OAuthTokenCache }
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Refresh([int]$TokenId) {
|
||||||
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
|
||||||
|
try {
|
||||||
|
$newToken = $this.AcquireFromState($TokenId)
|
||||||
|
return [bool]$newToken
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "OAuth provider: refresh failed for TokenId=$TokenId" $_.Exception
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-run whatever flow originally minted this entry, replacing the access
|
||||||
|
# token (and refresh_token, where applicable) in place. Fires
|
||||||
|
# AuthenticationTokenRefresh on success. Used both by the Refresh() public
|
||||||
|
# method and by GetAccessToken's preflight expiry check.
|
||||||
|
Hidden [string] AcquireFromState([int]$TokenId) {
|
||||||
|
return $this.AcquireFromState($TokenId, $null)
|
||||||
|
}
|
||||||
|
|
||||||
|
# $Claims carries a CAE claims challenge (from a Graph 401) into the /token
|
||||||
|
# request so the re-minted token satisfies the tenant's policy change.
|
||||||
|
Hidden [string] AcquireFromState([int]$TokenId, [string]$Claims) {
|
||||||
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||||
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||||
|
$cred = $entry.CredentialState
|
||||||
|
$defaultScope = "$($cred.Resource)/.default"
|
||||||
|
|
||||||
|
$tokenResp = $null
|
||||||
|
switch($cred.AuthMethod) {
|
||||||
|
'BYO' {
|
||||||
|
# BYO bearer can't be silently refreshed — caller must Connect again.
|
||||||
|
Write-Log "OAuth provider: BYO token (TokenId=$TokenId) cannot be refreshed automatically" 2
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
'IMDS' {
|
||||||
|
if($Claims) {
|
||||||
|
# IMDS / App Service token endpoints don't accept a claims
|
||||||
|
# parameter — a CAE challenge can't be satisfied here.
|
||||||
|
Write-Log "OAuth provider: managed identity tokens cannot satisfy a CAE claims challenge (TokenId=$TokenId)" 2
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$tokenResp = Invoke-OAuthIMDS -Resource $cred.Resource -ClientId $cred.ClientId
|
||||||
|
}
|
||||||
|
'Federated' {
|
||||||
|
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||||
|
grant_type = 'client_credentials'
|
||||||
|
client_id = $cred.ClientId
|
||||||
|
scope = $defaultScope
|
||||||
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||||
|
client_assertion = $assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Certificate' {
|
||||||
|
$assertion = Get-OAuthClientAssertion -Certificate $cred.Certificate -ClientId $cred.ClientId -Authority $cred.Authority -TenantId $cred.TenantId
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||||
|
grant_type = 'client_credentials'
|
||||||
|
client_id = $cred.ClientId
|
||||||
|
scope = $defaultScope
|
||||||
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||||
|
client_assertion = $assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'ClientSecret' {
|
||||||
|
$secretPlain = [System.Net.NetworkCredential]::new("", $cred.Secret).Password
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||||
|
grant_type = 'client_credentials'
|
||||||
|
client_id = $cred.ClientId
|
||||||
|
client_secret = $secretPlain
|
||||||
|
scope = $defaultScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'DeviceCode' {
|
||||||
|
# Silent-only here: never re-prompt with a new device code from a
|
||||||
|
# refresh path. No refresh token → the 401/expiry surfaces and the
|
||||||
|
# user re-runs Connect with -DeviceCode explicitly.
|
||||||
|
if(-not $entry.RefreshToken) {
|
||||||
|
Write-Log "OAuth provider: device-code token (TokenId=$TokenId) has no refresh token - run Connect-IntuneManagement -DeviceCode again" 2
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$dcTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $dcTenant -Claims $Claims -Body @{
|
||||||
|
grant_type = 'refresh_token'
|
||||||
|
client_id = $cred.ClientId
|
||||||
|
refresh_token = $entry.RefreshToken
|
||||||
|
scope = "$defaultScope offline_access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'AuthCode' {
|
||||||
|
# Browser (auth-code) refresh is silent - the refresh_token grant,
|
||||||
|
# identical to DeviceCode. No browser/redirect needed. Missing refresh
|
||||||
|
# token means the user must sign in again.
|
||||||
|
if(-not $entry.RefreshToken) {
|
||||||
|
Write-Log "OAuth provider: browser token (TokenId=$TokenId) has no refresh token - sign in again" 2
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$acTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $acTenant -Claims $Claims -Body @{
|
||||||
|
grant_type = 'refresh_token'
|
||||||
|
client_id = $cred.ClientId
|
||||||
|
refresh_token = $entry.RefreshToken
|
||||||
|
scope = "$defaultScope offline_access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Password' {
|
||||||
|
# Prefer refresh_token grant if we got one; falls back to ROPC re-prompt.
|
||||||
|
if($entry.RefreshToken) {
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||||
|
grant_type = 'refresh_token'
|
||||||
|
client_id = $cred.ClientId
|
||||||
|
refresh_token = $entry.RefreshToken
|
||||||
|
scope = "$defaultScope offline_access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$passwordPlain = [System.Net.NetworkCredential]::new("", $cred.Password).Password
|
||||||
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
||||||
|
grant_type = 'password'
|
||||||
|
client_id = $cred.ClientId
|
||||||
|
username = $cred.Username
|
||||||
|
password = $passwordPlain
|
||||||
|
scope = "$defaultScope offline_access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
throw "OAuth provider: unknown AuthMethod '$($cred.AuthMethod)' on TokenId=$TokenId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(-not $tokenResp -or -not $tokenResp.access_token) {
|
||||||
|
Write-Log "OAuth provider: refresh request returned no access_token for TokenId=$TokenId" 3
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
||||||
|
if($tokenResp.expires_in) {
|
||||||
|
$expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in)
|
||||||
|
}
|
||||||
|
$entry.AccessToken = [string]$tokenResp.access_token
|
||||||
|
if($tokenResp.refresh_token) { $entry.RefreshToken = [string]$tokenResp.refresh_token }
|
||||||
|
$entry.ExpiresAt = $expiresAt
|
||||||
|
$entry.AcquiredAt = [DateTime]::UtcNow
|
||||||
|
[AuthenticationOAuth]::Tokens[$TokenId] = $entry
|
||||||
|
|
||||||
|
Write-LogDebug "OAuth provider: refreshed TokenId=$TokenId (method=$($cred.AuthMethod), expires=$($expiresAt.ToLocalTime().ToString('s')))"
|
||||||
|
|
||||||
|
# Re-persist the rotated refresh token for the browser flow when caching is on.
|
||||||
|
if($cred.AuthMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
|
||||||
|
[void](Save-OAuthTokenCache -Data @{
|
||||||
|
RefreshToken = $entry.RefreshToken
|
||||||
|
ClientId = $cred.ClientId
|
||||||
|
TenantId = $entry.TenantId
|
||||||
|
Cloud = $entry.Cloud
|
||||||
|
Authority = $cred.Authority
|
||||||
|
Resource = $cred.Resource
|
||||||
|
AuthMethod = 'AuthCode'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Update-AuthToken -TokenId $TokenId
|
||||||
|
return $entry.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
# CAE entry point — Invoke-MSGraphAPI calls this when Graph answers 401 with
|
||||||
|
# a WWW-Authenticate claims challenge. Re-mints the token with the challenge
|
||||||
|
# attached; returns the new access token, or $null when the flow can't
|
||||||
|
# satisfy claims (BYO, managed identity, no refresh token).
|
||||||
|
[string] AcquireWithClaims([int]$TokenId, [string]$ClaimsChallenge) {
|
||||||
|
return $this.AcquireFromState($TokenId, $ClaimsChallenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Satisfy a CAE claims challenge by re-running the credential flow with the claims
|
||||||
|
# attached to the /token body. Returns $null when the flow can't satisfy the claims
|
||||||
|
# (e.g. BYO / managed identity). OAuth has no interactive browser escalation, so
|
||||||
|
# $AllowInteractive is not used.
|
||||||
|
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
|
||||||
|
return $this.AcquireWithClaims($TokenId, $ClaimsChallenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Token retrieval ===
|
||||||
|
|
||||||
|
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||||
|
if($TokenId -le 0) {
|
||||||
|
# Caller didn't pin a TokenId; pick the most recently-acquired entry.
|
||||||
|
if([AuthenticationOAuth]::Tokens.Count -eq 0) { return $null }
|
||||||
|
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
|
||||||
|
$TokenId = $latest.Id
|
||||||
|
}
|
||||||
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||||
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||||
|
|
||||||
|
# 5-minute slack so we don't hand out a token that expires mid-request.
|
||||||
|
if($entry.ExpiresAt -le [DateTime]::UtcNow.AddMinutes(5)) {
|
||||||
|
$refreshed = $this.AcquireFromState($TokenId)
|
||||||
|
if($refreshed) { return $refreshed }
|
||||||
|
# Refresh failed; surface the stale token rather than $null and let
|
||||||
|
# Invoke-MSGraphAPI handle the inevitable 401 — same behavior as MSAL.
|
||||||
|
}
|
||||||
|
return [string]$entry.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||||
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) {
|
||||||
|
return [datetime]::MaxValue
|
||||||
|
}
|
||||||
|
# ExpiresAt is stored UTC; return LOCAL to match the provider contract (MSAL
|
||||||
|
# returns ExpiresOn.LocalDateTime) and the local-time comparisons in
|
||||||
|
# Invoke-MSGraphAPI / Test-DefaultTokenExpired. Returning raw UTC made callers
|
||||||
|
# in ahead-of-UTC timezones see a just-issued token as already expired.
|
||||||
|
return [AuthenticationOAuth]::Tokens[$TokenId].ExpiresAt.ToLocalTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Display / picker data ===
|
||||||
|
|
||||||
|
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||||
|
if($TokenId -le 0 -and [AuthenticationOAuth]::Tokens.Count -gt 0) {
|
||||||
|
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
|
||||||
|
$TokenId = $latest.Id
|
||||||
|
}
|
||||||
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
||||||
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
||||||
|
|
||||||
|
$upn = $null; $userId = $null; $appName = $null
|
||||||
|
try {
|
||||||
|
$jwt = Get-JWTtoken $entry.AccessToken
|
||||||
|
if($jwt -and $jwt.Payload) {
|
||||||
|
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
|
||||||
|
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
|
||||||
|
elseif($jwt.Payload.unique_name) { [string]$jwt.Payload.unique_name }
|
||||||
|
else { $null }
|
||||||
|
$userId = if($jwt.Payload.oid) { [string]$jwt.Payload.oid } else { $null }
|
||||||
|
$appName = if($jwt.Payload.app_displayname) { [string]$jwt.Payload.app_displayname } else { $null }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
# Map AuthMethod to the cross-provider taxonomy (Interactive / ClientCredential / BYO / ...).
|
||||||
|
$authType = switch ($entry.AuthMethod) {
|
||||||
|
'BYO' { 'BYO' }
|
||||||
|
'DeviceCode' { 'Interactive' }
|
||||||
|
'AuthCode' { 'Interactive' }
|
||||||
|
'IMDS' { 'ManagedIdentity' }
|
||||||
|
'Federated' { 'WorkloadFederation' }
|
||||||
|
'Certificate' { 'ClientCredential' }
|
||||||
|
'ClientSecret' { 'ClientCredential' }
|
||||||
|
'Password' { 'Password' }
|
||||||
|
default { [string]$entry.AuthMethod }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Caller-friendly default for headless flows: when there's no UPN
|
||||||
|
# (app-only token), display the app id.
|
||||||
|
$displayName = if($upn) { $upn } else { $entry.ClientId }
|
||||||
|
|
||||||
|
# Tenant display name — one /organization GET per tenant per session,
|
||||||
|
# mirroring AuthenticationMgGraph.TenantNameCache. App-only tokens
|
||||||
|
# without Organization.Read.All fail the lookup; the $null result is
|
||||||
|
# cached too so it isn't retried on every refresh event.
|
||||||
|
$tenantName = $null
|
||||||
|
if($entry.TenantId) {
|
||||||
|
if([AuthenticationOAuth]::TenantNameCache.ContainsKey($entry.TenantId)) {
|
||||||
|
$tenantName = [AuthenticationOAuth]::TenantNameCache[$entry.TenantId]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$tenantName = Get-OAuthTenantOrganizationName -Resource $entry.Resource -AccessToken $entry.AccessToken
|
||||||
|
[AuthenticationOAuth]::TenantNameCache[$entry.TenantId] = $tenantName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Provider = $this.Id
|
||||||
|
DisplayName = $displayName
|
||||||
|
UPN = $upn
|
||||||
|
UserId = $userId
|
||||||
|
TenantId = $entry.TenantId
|
||||||
|
TenantName = $tenantName
|
||||||
|
AppId = $entry.ClientId
|
||||||
|
AppName = $appName
|
||||||
|
AuthType = $authType
|
||||||
|
ExpiresOn = $entry.ExpiresAt.ToLocalTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetCachedAccounts() {
|
||||||
|
# Pure-headless: surface the token cache as the account list. Each entry
|
||||||
|
# represents one Connect() call within the session.
|
||||||
|
$rows = @()
|
||||||
|
foreach($entry in [AuthenticationOAuth]::Tokens.Values) {
|
||||||
|
$upn = $null
|
||||||
|
try {
|
||||||
|
$jwt = Get-JWTtoken $entry.AccessToken
|
||||||
|
if($jwt -and $jwt.Payload) {
|
||||||
|
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
|
||||||
|
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
|
||||||
|
else { $null }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
$rows += [PSCustomObject]@{
|
||||||
|
Provider = $this.Id
|
||||||
|
Username = if($upn) { $upn } else { $entry.ClientId }
|
||||||
|
UserId = $null
|
||||||
|
TenantId = $entry.TenantId
|
||||||
|
Native = $entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [PSCustomObject[]]$rows
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
||||||
|
# Tenant enumeration costs an extra Graph call (/tenants or /me/memberOf
|
||||||
|
# /transitive). Headless callers usually know which tenant they want;
|
||||||
|
# leave this as a v2 enhancement.
|
||||||
|
return [PSCustomObject[]]@()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve a -Certificate argument (thumbprint string OR X509Certificate2 OR
|
||||||
|
# path-to-pfx) into a usable X509Certificate2. Reuses the MSAL provider's
|
||||||
|
# resolver where available so behavior stays identical.
|
||||||
|
Hidden [System.Security.Cryptography.X509Certificates.X509Certificate2] ResolveCertificate($CertObject, [string]$Path, $Password) {
|
||||||
|
if($CertObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
|
||||||
|
return $CertObject
|
||||||
|
}
|
||||||
|
if($CertObject) {
|
||||||
|
# Treat as thumbprint string — search Cert: stores the same way MSAL does.
|
||||||
|
if(Get-Command Resolve-MSALCertificate -ErrorAction SilentlyContinue) {
|
||||||
|
$cert = Resolve-MSALCertificate $CertObject
|
||||||
|
if($cert) { return $cert }
|
||||||
|
}
|
||||||
|
# Fallback: walk Cert:\CurrentUser\My then Cert:\LocalMachine\My.
|
||||||
|
$thumb = ([string]$CertObject).Replace(' ', '').ToUpperInvariant()
|
||||||
|
foreach($store in @('Cert:\CurrentUser\My', 'Cert:\LocalMachine\My')) {
|
||||||
|
$hit = Get-ChildItem $store -ErrorAction SilentlyContinue | Where-Object Thumbprint -EQ $thumb | Select-Object -First 1
|
||||||
|
if($hit) { return $hit }
|
||||||
|
}
|
||||||
|
throw "Could not find certificate with thumbprint '$CertObject' in CurrentUser\My or LocalMachine\My"
|
||||||
|
}
|
||||||
|
if($Path) {
|
||||||
|
if(-not (Test-Path $Path)) { throw "Certificate file not found: $Path" }
|
||||||
|
return Get-PfxCertificate -FilePath $Path -Password $Password -ErrorAction Stop
|
||||||
|
}
|
||||||
|
throw "ResolveCertificate: neither object nor path was supplied"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#ImportOrder 25
|
||||||
|
|
||||||
|
# Base class for authentication providers.
|
||||||
|
#
|
||||||
|
# Naming convention: concrete providers are named Authentication<Backend>
|
||||||
|
# (AuthenticationMSAL, AuthenticationMgGraph, AuthenticationAz). The base class
|
||||||
|
# stays AuthenticationProvider — there's only one of those.
|
||||||
|
#
|
||||||
|
# Three concrete providers ship: AuthenticationMSAL (MSAL.NET), AuthenticationMgGraph
|
||||||
|
# (Microsoft.Graph SDK) and AuthenticationOAuth (pure PowerShell for automation).
|
||||||
|
#
|
||||||
|
# Concrete providers override what they support. Methods that MUST be overridden
|
||||||
|
# throw NotImplemented when called on the base; optional methods return safe defaults
|
||||||
|
# so a provider that doesn't support a feature simply reports back $false / $null.
|
||||||
|
#
|
||||||
|
# Return shapes for the *Info / *Accounts / *Tenants methods are uniform across all
|
||||||
|
# providers — that's the whole point of the abstraction. Consumers (Invoke-MSGraphAPI,
|
||||||
|
# profile dialog, account picker) read these uniform shapes directly. Invoke-MSGraphAPI
|
||||||
|
# resolves a call's OWNING provider by TokenId and gets the bearer from it (or lets the
|
||||||
|
# provider run the request), so MSAL / MgGraph / OAuth tokens can all be live at once.
|
||||||
|
#
|
||||||
|
# === Required capabilities (contract) ===
|
||||||
|
# Every concrete provider MUST support and prove out these auth modes:
|
||||||
|
# * Interactive login (SupportsInteractive = $true)
|
||||||
|
# * App with client secret (SupportsClientSecret = $true)
|
||||||
|
# * App with certificate (SupportsCertificate = $true)
|
||||||
|
# Setting any of these to $false on a concrete provider is a contract violation —
|
||||||
|
# the abstraction assumes consumers can pick any of the three.
|
||||||
|
#
|
||||||
|
# === Recommended capabilities ===
|
||||||
|
# Providers SHOULD also support, where the backend allows it:
|
||||||
|
# * Identity Provider login (SupportsIdentityProvider = $true)
|
||||||
|
# Covers managed identity, workload identity federation, and federated
|
||||||
|
# credential assertions. Not required because Connect-IntuneManagement
|
||||||
|
# doesn't yet expose those parameter sets, but expected for full coverage.
|
||||||
|
class AuthenticationProvider {
|
||||||
|
# Identity
|
||||||
|
[string]$Id
|
||||||
|
[string]$DisplayName
|
||||||
|
|
||||||
|
# Capability flags. The profile UI reads these to enable / disable buttons and the
|
||||||
|
# connect facade routes only to providers that support the requested mode.
|
||||||
|
[bool]$SupportsMultiTenant = $true
|
||||||
|
[bool]$SupportsRefresh = $true
|
||||||
|
[bool]$SupportsForget = $true
|
||||||
|
[bool]$SupportsCachedUsers = $true
|
||||||
|
|
||||||
|
# Required by contract. See block comment above.
|
||||||
|
[bool]$SupportsInteractive = $true
|
||||||
|
[bool]$SupportsClientSecret = $true
|
||||||
|
[bool]$SupportsCertificate = $true
|
||||||
|
|
||||||
|
# Recommended. Managed identity / workload identity federation / federated
|
||||||
|
# credential. Off by default — concrete providers opt in when implemented.
|
||||||
|
[bool]$SupportsIdentityProvider = $false
|
||||||
|
|
||||||
|
# Optional. Provider accepts an externally-issued bearer token (no auth flow).
|
||||||
|
[bool]$SupportsBYOToken = $false
|
||||||
|
|
||||||
|
# Optional. Provider can satisfy a CAE (Continuous Access Evaluation) claims
|
||||||
|
# challenge - a Graph 401 carrying a WWW-Authenticate claims="..." parameter - by
|
||||||
|
# re-minting the token via GetClaimsToken(). Providers whose SDK handles CAE
|
||||||
|
# internally, or that can't re-mint (pure BYO), leave this $false and the caller
|
||||||
|
# surfaces the 401 unchanged.
|
||||||
|
[bool]$SupportsClaimsChallenge = $false
|
||||||
|
|
||||||
|
# Optional. The provider is wired directly into the module's built-in
|
||||||
|
# Connect-EntraEnvironment / Connect-IntuneManagement entry points (the original
|
||||||
|
# pre-abstraction MSAL path). Callers that want that rich handling (sovereign-cloud
|
||||||
|
# -Cloud selection, ForceRefresh by TokenId, ...) drive such a provider through those
|
||||||
|
# functions instead of the generic Connect()/Refresh() hop, keeping behaviour and
|
||||||
|
# stack traces identical. Providers whose logic lives entirely in their own Connect()
|
||||||
|
# / Refresh() leave this $false.
|
||||||
|
[bool]$UsesBuiltInConnectPath = $false
|
||||||
|
|
||||||
|
# Optional. Provider can launch an interactive admin/user consent prompt for the
|
||||||
|
# app's delegated scopes (MSAL: Start-MSALConsentPrompt). The profile UI shows a
|
||||||
|
# "Request consent" action only when this is $true.
|
||||||
|
[bool]$SupportsConsentPrompt = $false
|
||||||
|
|
||||||
|
# The provider answers every Graph request itself through InvokeWebRequest,
|
||||||
|
# even though GetAccessToken returned a bearer. Off for the real providers -
|
||||||
|
# a bearer means "send it over HTTPS". On for a provider whose backend is not
|
||||||
|
# Graph at all (the offline mock tenant serves canned data from disk).
|
||||||
|
[bool]$RoutesAllRequests = $false
|
||||||
|
|
||||||
|
# Always Graph for now; -Resource is plumbed through so future phases can mint
|
||||||
|
# tokens for other audiences (Key Vault, Storage, etc.) without API changes.
|
||||||
|
[string]$DefaultResource = "https://graph.microsoft.com"
|
||||||
|
|
||||||
|
# Called once at module init for provider-specific setup (DLL loads, settings).
|
||||||
|
# Default is a no-op.
|
||||||
|
[void] Initialize() { }
|
||||||
|
|
||||||
|
# === Auth lifecycle ===
|
||||||
|
# Must override:
|
||||||
|
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||||
|
throw "Provider '$($this.Id)' does not implement Connect"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional (return $false if not supported):
|
||||||
|
[bool] Disconnect([int]$TokenId) { return $false }
|
||||||
|
[bool] ForgetAccount([string]$AccountIdentifier) { return $false }
|
||||||
|
[bool] Refresh([int]$TokenId) { return $false }
|
||||||
|
|
||||||
|
# List the tenants the signed-in account can reach, for a tenant picker or a
|
||||||
|
# pre-flight check before Connect -TenantId. Microsoft Graph has no API for
|
||||||
|
# this: findTenantInformationByTenantId resolves ONE tenant you already know,
|
||||||
|
# and the multi-tenant-organization APIs only cover a configured MTO. The only
|
||||||
|
# general source is Azure Resource Manager's /tenants, which needs a token for
|
||||||
|
# a different audience - so a provider can implement this only if it can mint
|
||||||
|
# one for the same account.
|
||||||
|
#
|
||||||
|
# Return $null when the provider cannot enumerate (the default). Otherwise
|
||||||
|
# return @{ Tenants = <rows>; ConsentMissing = <bool>; Message = <string> } so
|
||||||
|
# the caller can tell "no tenants" from "the app lacks the permission".
|
||||||
|
[PSCustomObject] GetAccessibleTenants([int]$TokenId) { return $null }
|
||||||
|
[PSCustomObject] SwitchTenant([int]$TokenId, [string]$NewTenantId) { return $null }
|
||||||
|
|
||||||
|
# Called once at app startup (AppInitialized event) for the active provider only.
|
||||||
|
# Implementations should attempt a SILENT (non-interactive) sign-in if their backend
|
||||||
|
# has persisted credentials on disk. Return $true if the user is now signed in,
|
||||||
|
# $false otherwise. Default is no-op — MSAL has its own cross-session refresh path
|
||||||
|
# via $script:MSALDefaultToken on startup, so it doesn't need this hook.
|
||||||
|
[bool] TryResumeSession() { return $false }
|
||||||
|
|
||||||
|
# Optional. Cheap SILENT ambient-session refresh, invoked on view activation to keep
|
||||||
|
# the default token fresh without ever prompting. Providers that have no such
|
||||||
|
# mechanism - or where a silent auth here could hijack the active session - leave the
|
||||||
|
# base no-op. (MSAL refreshes via Connect-EntraEnvironment -ForceSilent.)
|
||||||
|
[void] RefreshAmbientSession() { }
|
||||||
|
|
||||||
|
# === Token retrieval ===
|
||||||
|
# Must override. -Resource is informational for providers that mint per-audience
|
||||||
|
# tokens; today only Graph is required.
|
||||||
|
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||||
|
throw "Provider '$($this.Id)' does not implement GetAccessToken"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional. Returns DateTime.MaxValue when expiry is unknown (callers should treat
|
||||||
|
# MaxValue as "no preflight refresh needed").
|
||||||
|
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||||
|
return [datetime]::MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional. Re-mint an access token to satisfy a CAE claims challenge returned by
|
||||||
|
# the resource (Graph 401 -> WWW-Authenticate claims="..."). Implemented only by
|
||||||
|
# providers with SupportsClaimsChallenge = $true; the base returns $null so a
|
||||||
|
# provider that can't satisfy the challenge simply lets the 401 surface.
|
||||||
|
# $Resource - target audience (informational; Graph today)
|
||||||
|
# $ClaimsChallenge - the claims value parsed from the 401 challenge
|
||||||
|
# $AllowInteractive - caller context: $true when a UI is present and this is NOT a
|
||||||
|
# nested auth-flow call, so the provider MAY prompt if it can't
|
||||||
|
# satisfy the challenge silently; $false forces silent-only.
|
||||||
|
# Returns the new access token, or $null if the challenge couldn't be satisfied.
|
||||||
|
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional. Providers whose backend SDK owns the HTTP pipeline and won't hand out a
|
||||||
|
# raw bearer token override this to run the request themselves and return a response
|
||||||
|
# object shaped like Invoke-WebRequest's (StatusCode / Headers / Content /
|
||||||
|
# RawContentLength). Return $null to signal "I don't route requests - use the raw
|
||||||
|
# access token from GetAccessToken via Invoke-WebRequest". Base default: $null.
|
||||||
|
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Display / picker data (uniform shapes for UI consumption) ===
|
||||||
|
|
||||||
|
# Returns a PSCustomObject with these properties (or $null if unknown):
|
||||||
|
# Provider - this.Id
|
||||||
|
# DisplayName - user's display name
|
||||||
|
# UPN - user principal name (login id)
|
||||||
|
# UserId - tenant-scoped object id
|
||||||
|
# TenantId - GUID
|
||||||
|
# TenantName - organization display name
|
||||||
|
# AppId - Entra app client id
|
||||||
|
# AppName - Entra app display name
|
||||||
|
# AuthType - "Interactive" | "ClientCredential" | "BYO" | "DeviceCode" | ...
|
||||||
|
# ExpiresOn - DateTime (local) or $null
|
||||||
|
[PSCustomObject] GetUserInfo([int]$TokenId) { return $null }
|
||||||
|
|
||||||
|
# Returns PSCustomObject[] with these per-row properties:
|
||||||
|
# Provider, Username, UserId, TenantId, Native (provider-opaque)
|
||||||
|
[PSCustomObject[]] GetCachedAccounts() { return [PSCustomObject[]]@() }
|
||||||
|
|
||||||
|
# Returns PSCustomObject[] with these per-row properties:
|
||||||
|
# Provider, TenantId, TenantName, Native
|
||||||
|
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) { return [PSCustomObject[]]@() }
|
||||||
|
|
||||||
|
# Returns Name/Value rows describing the provider's native session/context for the
|
||||||
|
# profile "Session Info" inspector (MSAL: AuthenticationResult fields; MgGraph:
|
||||||
|
# Get-MgContext properties). Base default: no rows.
|
||||||
|
[PSCustomObject[]] GetSessionInfoRows() { return [PSCustomObject[]]@() }
|
||||||
|
|
||||||
|
# Decoded id-token JWT ({Header, Payload}) for the profile popup's "Id Token"
|
||||||
|
# inspector, or $null when the provider doesn't surface an id token. The UI shows
|
||||||
|
# the Id Token button only when this returns non-null, so non-MSAL providers hide
|
||||||
|
# it automatically. Keeps raw-JWT knowledge inside the owning provider.
|
||||||
|
[object] GetIdTokenJwt([int]$TokenId) { return $null }
|
||||||
|
|
||||||
|
# === Provider-specific UI hook ===
|
||||||
|
# MSAL adds "MSAL Token / Access Token / ID Token" inspector buttons here, for
|
||||||
|
# example. Returns a WPF element to splice into the profile popup, or $null.
|
||||||
|
[object] BuildLoginMenu([object]$Window) { return $null }
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
#ImportOrder 30
|
||||||
|
|
||||||
|
class CompareProviderBase
|
||||||
|
{
|
||||||
|
[string] $Name
|
||||||
|
[string] $Value
|
||||||
|
[string] $OptionsXaml
|
||||||
|
[string[]] $RemoveProperties = @()
|
||||||
|
[bool] $IgnoreGroups = $false
|
||||||
|
# Skip objects that exist on only one side. Source = the reverse pass
|
||||||
|
# (objects only in the counterpart set); Destination = the forward pass
|
||||||
|
# (objects whose looked-up counterpart is missing). Same semantics as the
|
||||||
|
# original project's Skip Missing Source/Destination Policies checkboxes.
|
||||||
|
[bool] $SkipMissingSourcePolicies = $false
|
||||||
|
[bool] $SkipMissingDestinationPolicies = $false
|
||||||
|
|
||||||
|
CompareProviderBase([string]$name, [string]$value, [string]$optionsXaml)
|
||||||
|
{
|
||||||
|
$this.Name = $name
|
||||||
|
$this.Value = $value
|
||||||
|
$this.OptionsXaml = $optionsXaml
|
||||||
|
}
|
||||||
|
|
||||||
|
[object[]] GetComparePairs([object[]]$groups) { return @() }
|
||||||
|
[bool] Validate() { return $true }
|
||||||
|
[void] SaveSettings() {}
|
||||||
|
[string] ToString() { return $this.Name }
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompareExportFilesProvider : CompareProviderBase
|
||||||
|
{
|
||||||
|
[string] $ExportPath
|
||||||
|
[string] $NameFilter
|
||||||
|
|
||||||
|
CompareExportFilesProvider() : base(
|
||||||
|
"Exported Files with Intune Objects (Id)",
|
||||||
|
"export",
|
||||||
|
"CompareExportOptions"
|
||||||
|
) {}
|
||||||
|
|
||||||
|
[object[]] GetComparePairs([object[]]$groups)
|
||||||
|
{
|
||||||
|
$pairs = @()
|
||||||
|
|
||||||
|
foreach($group in $groups)
|
||||||
|
{
|
||||||
|
foreach($policyType in $group.PolicyTypes)
|
||||||
|
{
|
||||||
|
$folder = [IO.Path]::Combine($this.ExportPath, $policyType.Folder)
|
||||||
|
if(-not [IO.Directory]::Exists($folder))
|
||||||
|
{
|
||||||
|
Write-Log "Folder '$folder' not found. Skipping." 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Save-SettingStoreValue "" "LastUsedFullPath" $folder
|
||||||
|
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
|
||||||
|
|
||||||
|
$fileObjs = @(Get-PoliciesFromFolder -Path $folder -PolicyTypes @($policyType))
|
||||||
|
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID | Where-Object { $_.PolicyType.ID -eq $policyType.ID })
|
||||||
|
|
||||||
|
$pairedFileIds = @()
|
||||||
|
foreach($fileObj in $fileObjs)
|
||||||
|
{
|
||||||
|
$objName = $fileObj.Name
|
||||||
|
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||||
|
if(-not $fileObj.ID) { Write-Log "File '$objName' has no Id. Skipping." 2; continue }
|
||||||
|
|
||||||
|
$intuneObj = $intuneObjs | Where-Object { $_.ID -eq $fileObj.ID }
|
||||||
|
if($intuneObj) { $policyType.GetFullObject($intuneObj) | Out-Null }
|
||||||
|
elseif($this.SkipMissingDestinationPolicies) { continue }
|
||||||
|
else { Write-Log "Object '$objName' with id $($fileObj.ID) not found in Intune. Deleted?" 2 }
|
||||||
|
|
||||||
|
$pairedFileIds += [string]$fileObj.ID
|
||||||
|
$pairs += [PSCustomObject]@{
|
||||||
|
Name = $objName
|
||||||
|
Id = $fileObj.ID
|
||||||
|
PolicyType = $policyType
|
||||||
|
SaveFolder = $folder
|
||||||
|
Policy1 = $intuneObj
|
||||||
|
Policy2 = $fileObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(-not $this.SkipMissingSourcePolicies)
|
||||||
|
{
|
||||||
|
# Objects that exist in Intune but were never exported.
|
||||||
|
foreach($intuneObj in $intuneObjs)
|
||||||
|
{
|
||||||
|
if([string]$intuneObj.ID -in $pairedFileIds) { continue }
|
||||||
|
$objName = $intuneObj.Name
|
||||||
|
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||||
|
|
||||||
|
Write-Log "Object '$objName' with id $($intuneObj.ID) exists in Intune but has no exported file. New object?" 2
|
||||||
|
$pairs += [PSCustomObject]@{
|
||||||
|
Name = $objName
|
||||||
|
Id = $intuneObj.ID
|
||||||
|
PolicyType = $policyType
|
||||||
|
SaveFolder = $folder
|
||||||
|
Policy1 = $intuneObj
|
||||||
|
Policy2 = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] SaveSettings()
|
||||||
|
{
|
||||||
|
# No persisted options: the "Compare\ExportPath" write that used to live here
|
||||||
|
# was never read back anywhere (dead since the port).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompareIntuneWithExportProvider : CompareProviderBase
|
||||||
|
{
|
||||||
|
[string] $ExportPath
|
||||||
|
[string] $NameFilter
|
||||||
|
|
||||||
|
CompareIntuneWithExportProvider() : base(
|
||||||
|
"Intune Objects with Exported Files (Name)",
|
||||||
|
"IntuneWithExport",
|
||||||
|
"CompareExportOptions"
|
||||||
|
) {}
|
||||||
|
|
||||||
|
[object[]] GetComparePairs([object[]]$groups)
|
||||||
|
{
|
||||||
|
$pairs = @()
|
||||||
|
|
||||||
|
foreach($group in $groups)
|
||||||
|
{
|
||||||
|
foreach($policyType in $group.PolicyTypes)
|
||||||
|
{
|
||||||
|
$folder = [IO.Path]::Combine($this.ExportPath, $policyType.Folder)
|
||||||
|
if(-not [IO.Directory]::Exists($folder))
|
||||||
|
{
|
||||||
|
Write-Log "Folder '$folder' not found. Skipping." 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Save-SettingStoreValue "" "LastUsedFullPath" $folder
|
||||||
|
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
|
||||||
|
|
||||||
|
$fileObjs = @(Get-PoliciesFromFolder -Path $folder -PolicyTypes @($policyType))
|
||||||
|
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID | Where-Object { $_.PolicyType.ID -eq $policyType.ID })
|
||||||
|
|
||||||
|
$pairedFileNames = @()
|
||||||
|
foreach($intuneObj in $intuneObjs)
|
||||||
|
{
|
||||||
|
$objName = $intuneObj.Name
|
||||||
|
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||||
|
|
||||||
|
$fileObj = $fileObjs | Where-Object { $_.Name -eq $objName }
|
||||||
|
if(($fileObj | Measure-Object).Count -gt 1)
|
||||||
|
{
|
||||||
|
Write-Log "Multiple file objects with name '$objName'. Skipping." 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if($fileObj) {
|
||||||
|
$policyType.GetFullObject($intuneObj) | Out-Null
|
||||||
|
$pairedFileNames += [string]$objName
|
||||||
|
}
|
||||||
|
elseif($this.SkipMissingDestinationPolicies) { continue }
|
||||||
|
else { Write-Log "Object '$objName' with id $($intuneObj.ID) not found in exported folder. New object?" 2 }
|
||||||
|
|
||||||
|
$pairs += [PSCustomObject]@{
|
||||||
|
Name = $objName
|
||||||
|
Id = $intuneObj.ID
|
||||||
|
PolicyType = $policyType
|
||||||
|
SaveFolder = $folder
|
||||||
|
Policy1 = $intuneObj
|
||||||
|
Policy2 = $fileObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(-not $this.SkipMissingSourcePolicies)
|
||||||
|
{
|
||||||
|
# Exported files with no matching Intune object.
|
||||||
|
foreach($fileObj in $fileObjs)
|
||||||
|
{
|
||||||
|
$objName = $fileObj.Name
|
||||||
|
if([string]$objName -in $pairedFileNames) { continue }
|
||||||
|
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||||
|
|
||||||
|
Write-Log "File object '$objName' has no matching Intune object. Deleted?" 2
|
||||||
|
$pairs += [PSCustomObject]@{
|
||||||
|
Name = $objName
|
||||||
|
Id = $fileObj.ID
|
||||||
|
PolicyType = $policyType
|
||||||
|
SaveFolder = $folder
|
||||||
|
Policy1 = $null
|
||||||
|
Policy2 = $fileObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] SaveSettings()
|
||||||
|
{
|
||||||
|
# No persisted options: the "Compare\ExportPath" write that used to live here
|
||||||
|
# was never read back anywhere (dead since the port).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompareNamedObjectsProvider : CompareProviderBase
|
||||||
|
{
|
||||||
|
[string] $SourcePattern
|
||||||
|
[string] $ComparePattern
|
||||||
|
[string] $SavePath
|
||||||
|
[string[]] $RemoveProperties = @("Id")
|
||||||
|
|
||||||
|
CompareNamedObjectsProvider() : base(
|
||||||
|
"Named Objects in Intune",
|
||||||
|
"name",
|
||||||
|
"CompareNamedOptions"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
$this.RemoveProperties = @("Id")
|
||||||
|
}
|
||||||
|
|
||||||
|
[object[]] GetComparePairs([object[]]$groups)
|
||||||
|
{
|
||||||
|
if(-not $this.SourcePattern -or -not $this.ComparePattern)
|
||||||
|
{
|
||||||
|
throw "Both source and compare name patterns must be specified"
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputFolder = $this.SavePath
|
||||||
|
if(-not $outputFolder) { $outputFolder = [Environment]::GetFolderPath("MyDocuments") }
|
||||||
|
|
||||||
|
$pairs = @()
|
||||||
|
|
||||||
|
foreach($group in $groups)
|
||||||
|
{
|
||||||
|
Write-Status "Compare $($group.Title) objects" -Force -SkipLog
|
||||||
|
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID)
|
||||||
|
|
||||||
|
foreach($intuneObj in ($intuneObjs | Where-Object { $_.Name -imatch [regex]::Escape($this.SourcePattern) }))
|
||||||
|
{
|
||||||
|
$sourceName = $intuneObj.Name
|
||||||
|
$compareName = $sourceName -ireplace [regex]::Escape($this.SourcePattern), $this.ComparePattern
|
||||||
|
|
||||||
|
$compareObj = $intuneObjs | Where-Object { $_.Name -eq $compareName -and $_.Object.'@OData.Type' -eq $intuneObj.Object.'@OData.Type' }
|
||||||
|
if(($compareObj | Measure-Object).Count -gt 1)
|
||||||
|
{
|
||||||
|
Write-Log "Multiple objects named '$compareName'. Skipping." 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if($compareObj)
|
||||||
|
{
|
||||||
|
$intuneObj.PolicyType.GetFullObject($intuneObj) | Out-Null
|
||||||
|
$compareObj.PolicyType.GetFullObject($compareObj) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$pairs += [PSCustomObject]@{
|
||||||
|
Name = $sourceName
|
||||||
|
Id = $intuneObj.ID
|
||||||
|
PolicyType = $intuneObj.PolicyType
|
||||||
|
SaveFolder = $outputFolder
|
||||||
|
Policy1 = $intuneObj
|
||||||
|
Policy2 = $compareObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] SaveSettings()
|
||||||
|
{
|
||||||
|
# No persisted options: the CompareSource / CompareWith / SavePath writes that
|
||||||
|
# used to live here were never read back anywhere (dead since the port).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompareExportedFoldersProvider : CompareProviderBase
|
||||||
|
{
|
||||||
|
[string] $SourcePath
|
||||||
|
[string] $ComparePath
|
||||||
|
[string] $NameFilter
|
||||||
|
|
||||||
|
CompareExportedFoldersProvider() : base(
|
||||||
|
"Files in Exported Folders",
|
||||||
|
"exportedFolders",
|
||||||
|
"CompareExportedFilesOptions"
|
||||||
|
) {}
|
||||||
|
|
||||||
|
[object[]] GetComparePairs([object[]]$groups)
|
||||||
|
{
|
||||||
|
if(-not $this.SourcePath -or -not $this.ComparePath)
|
||||||
|
{
|
||||||
|
throw "Both source and compare folders must be specified"
|
||||||
|
}
|
||||||
|
if(-not [IO.Directory]::Exists($this.SourcePath))
|
||||||
|
{
|
||||||
|
throw "Source folder '$($this.SourcePath)' does not exist"
|
||||||
|
}
|
||||||
|
if(-not [IO.Directory]::Exists($this.ComparePath))
|
||||||
|
{
|
||||||
|
throw "Compare folder '$($this.ComparePath)' does not exist"
|
||||||
|
}
|
||||||
|
|
||||||
|
$pairs = @()
|
||||||
|
|
||||||
|
foreach($group in $groups)
|
||||||
|
{
|
||||||
|
foreach($policyType in $group.PolicyTypes)
|
||||||
|
{
|
||||||
|
$folderSrc = [IO.Path]::Combine($this.SourcePath, $policyType.Folder)
|
||||||
|
$folderCmp = [IO.Path]::Combine($this.ComparePath, $policyType.Folder)
|
||||||
|
|
||||||
|
if(-not [IO.Directory]::Exists($folderSrc)) { Write-Log "Source folder '$folderSrc' not found. Skipping." 2; continue }
|
||||||
|
|
||||||
|
Save-SettingStoreValue "" "LastUsedFullPath" $folderSrc
|
||||||
|
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
|
||||||
|
|
||||||
|
$srcObjs = @(Get-PoliciesFromFolder -Path $folderSrc -PolicyTypes @($policyType))
|
||||||
|
$cmpObjs = @()
|
||||||
|
if([IO.Directory]::Exists($folderCmp))
|
||||||
|
{
|
||||||
|
$cmpObjs = @(Get-PoliciesFromFolder -Path $folderCmp -PolicyTypes @($policyType))
|
||||||
|
}
|
||||||
|
|
||||||
|
$addedIds = @()
|
||||||
|
|
||||||
|
foreach($srcObj in $srcObjs)
|
||||||
|
{
|
||||||
|
$objName = $srcObj.Name
|
||||||
|
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||||
|
if(-not $srcObj.ID) { Write-Log "File '$objName' has no Id. Skipping." 2; continue }
|
||||||
|
|
||||||
|
$cmpObj = $cmpObjs | Where-Object { $_.ID -eq $srcObj.ID }
|
||||||
|
$addedIds += $srcObj.ID
|
||||||
|
if(-not $cmpObj -and $this.SkipMissingDestinationPolicies) { continue }
|
||||||
|
|
||||||
|
$pairs += [PSCustomObject]@{
|
||||||
|
Name = $objName
|
||||||
|
Id = $srcObj.ID
|
||||||
|
PolicyType = $policyType
|
||||||
|
SaveFolder = $folderSrc
|
||||||
|
Policy1 = $srcObj
|
||||||
|
Policy2 = $cmpObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach($cmpObj in $cmpObjs)
|
||||||
|
{
|
||||||
|
if($this.SkipMissingSourcePolicies) { continue }
|
||||||
|
if($cmpObj.ID -in $addedIds) { continue }
|
||||||
|
$objName = $cmpObj.Name
|
||||||
|
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
|
||||||
|
|
||||||
|
$pairs += [PSCustomObject]@{
|
||||||
|
Name = $objName
|
||||||
|
Id = $cmpObj.ID
|
||||||
|
PolicyType = $policyType
|
||||||
|
SaveFolder = $folderSrc
|
||||||
|
Policy1 = $null
|
||||||
|
Policy2 = $cmpObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] SaveSettings()
|
||||||
|
{
|
||||||
|
# No persisted options: the SourcePath / ComparePath writes that used to live
|
||||||
|
# here were never read back anywhere (dead since the port).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#ImportOrder 31
|
||||||
|
|
||||||
|
class CompareOutputProviderBase
|
||||||
|
{
|
||||||
|
[string] $Name
|
||||||
|
[string] $Value
|
||||||
|
[string] $Extension
|
||||||
|
|
||||||
|
CompareOutputProviderBase([string]$name, [string]$value, [string]$extension)
|
||||||
|
{
|
||||||
|
$this.Name = $name
|
||||||
|
$this.Value = $value
|
||||||
|
$this.Extension = $extension
|
||||||
|
}
|
||||||
|
|
||||||
|
[string] FormatRows([object[]]$rows, [string[]]$props) { return "" }
|
||||||
|
[string] ToString() { return $this.Name }
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompareCSVOutputProvider : CompareOutputProviderBase
|
||||||
|
{
|
||||||
|
[string] $Delimiter = ";"
|
||||||
|
|
||||||
|
CompareCSVOutputProvider() : base("CSV", "csv", "csv")
|
||||||
|
{
|
||||||
|
$this.Delimiter = ";"
|
||||||
|
}
|
||||||
|
|
||||||
|
[string] FormatRows([object[]]$rows, [string[]]$props)
|
||||||
|
{
|
||||||
|
$selected = @($rows | Select-Object -Property $props)
|
||||||
|
if($this.Delimiter -and $this.Delimiter.Length -eq 1)
|
||||||
|
{
|
||||||
|
return ($selected | ConvertTo-Csv -NoTypeInformation -Delimiter ([char]$this.Delimiter)) -join [System.Environment]::NewLine
|
||||||
|
}
|
||||||
|
return ($selected | ConvertTo-Csv -NoTypeInformation) -join [System.Environment]::NewLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompareJsonOutputProvider : CompareOutputProviderBase
|
||||||
|
{
|
||||||
|
CompareJsonOutputProvider() : base("JSON", "json", "json") {}
|
||||||
|
|
||||||
|
[string] FormatRows([object[]]$rows, [string[]]$props)
|
||||||
|
{
|
||||||
|
return $rows | Select-Object -Property $props | ConvertTo-Json -Depth 20
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#ImportOrder 29
|
||||||
|
|
||||||
|
# Self-registration registry for the compare subsystem, mirroring
|
||||||
|
# [DocumentationRegistry]. Replaces the hardcoded $script:compare* arrays that
|
||||||
|
# Initialize-CompareModule used to build - which only the WPF AppInitialized
|
||||||
|
# handler ever ran, so in Avalonia mode the compare combos came up empty.
|
||||||
|
# Providers, output providers, and comparison types now register once at module
|
||||||
|
# load (Internal/Compare.ps1) so BOTH UI backends see the same catalog, and the
|
||||||
|
# documentation subsystem self-registers its own "doc" comparison type instead
|
||||||
|
# of Compare.ps1 reaching across with a Get-Command probe.
|
||||||
|
#
|
||||||
|
# Loaded at #ImportOrder 29 - before CompareClasses.ps1 (30) and
|
||||||
|
# CompareOutputClasses.ps1 (31) - so the provider/output classes it stores are
|
||||||
|
# already defined by the time Internal/Compare.ps1 registers instances.
|
||||||
|
#
|
||||||
|
# Dedupe is by .Value (a provider/output/type key), matching the
|
||||||
|
# DocumentationRegistry replace-by-identity semantics so Import-Module -Force
|
||||||
|
# re-registration never accumulates duplicates.
|
||||||
|
class CompareRegistry {
|
||||||
|
static [System.Collections.Generic.List[object]] $Providers = [System.Collections.Generic.List[object]]::new()
|
||||||
|
static [System.Collections.Generic.List[object]] $OutputProviders = [System.Collections.Generic.List[object]]::new()
|
||||||
|
static [System.Collections.Generic.List[PSCustomObject]] $ComparisonTypes = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
|
||||||
|
# ---- Compare providers (CompareProviderBase subclasses) ----
|
||||||
|
static [void] RegisterProvider([object]$Provider) {
|
||||||
|
if (-not $Provider.Value) { throw "Compare provider must have a Value" }
|
||||||
|
$existing = [CompareRegistry]::Providers | Where-Object { $_.Value -eq $Provider.Value }
|
||||||
|
if ($existing) { [CompareRegistry]::Providers.Remove($existing) | Out-Null }
|
||||||
|
[CompareRegistry]::Providers.Add($Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
static [object] FindProvider([string]$Value) {
|
||||||
|
return [CompareRegistry]::Providers | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Output providers (CompareOutputProviderBase subclasses) ----
|
||||||
|
static [void] RegisterOutputProvider([object]$Provider) {
|
||||||
|
if (-not $Provider.Value) { throw "Compare output provider must have a Value" }
|
||||||
|
$existing = [CompareRegistry]::OutputProviders | Where-Object { $_.Value -eq $Provider.Value }
|
||||||
|
if ($existing) { [CompareRegistry]::OutputProviders.Remove($existing) | Out-Null }
|
||||||
|
[CompareRegistry]::OutputProviders.Add($Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
static [object] FindOutputProvider([string]$Value) {
|
||||||
|
return [CompareRegistry]::OutputProviders | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
static [object] FindOutputProviderByExtension([string]$Extension) {
|
||||||
|
return [CompareRegistry]::OutputProviders | Where-Object { $_.Extension -eq $Extension } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Comparison types (PSCustomObject: Name, Value, [Compare], [RemoveProperties]) ----
|
||||||
|
static [void] RegisterComparisonType([PSCustomObject]$Type) {
|
||||||
|
if (-not $Type.Value) { throw "Comparison type must have a Value" }
|
||||||
|
$existing = [CompareRegistry]::ComparisonTypes | Where-Object { $_.Value -eq $Type.Value }
|
||||||
|
if ($existing) { [CompareRegistry]::ComparisonTypes.Remove($existing) | Out-Null }
|
||||||
|
[CompareRegistry]::ComparisonTypes.Add($Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
static [PSCustomObject] FindComparisonType([string]$Value) {
|
||||||
|
return [CompareRegistry]::ComparisonTypes | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear every collection. Called once at the top of the module-load
|
||||||
|
# registration in Internal/Compare.ps1 so Import-Module -Force starts clean
|
||||||
|
# (the static initializers above only run on first type load; the type is
|
||||||
|
# cached across -Force reimports).
|
||||||
|
static [void] Reset() {
|
||||||
|
[CompareRegistry]::Providers.Clear()
|
||||||
|
[CompareRegistry]::OutputProviders.Clear()
|
||||||
|
[CompareRegistry]::ComparisonTypes.Clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#ImportOrder 1
|
||||||
|
class ViewObjectBase
|
||||||
|
{
|
||||||
|
Hidden [String]$_ID = $null
|
||||||
|
Hidden [String]$_Title = $null
|
||||||
|
Hidden [String]$_Description = $null
|
||||||
|
Hidden [String]$_AuthenticaionObject = $null
|
||||||
|
Hidden [Boolean]$_HideMenu = $false
|
||||||
|
Hidden [Object]$_ViewPanel = $null
|
||||||
|
Hidden [Guid]$_SessionId = [Guid]::Empty
|
||||||
|
Hidden [Boolean]$_AddToMenu = $true
|
||||||
|
# When true, the top-bar Views menu surfaces this view's items as a
|
||||||
|
# submenu beneath the view entry — clicking a child both activates the
|
||||||
|
# parent view and selects that item in the left nav. Default off because
|
||||||
|
# most views (IntuneManagement with ~50 policy types) would balloon the
|
||||||
|
# Views menu past usability.
|
||||||
|
Hidden [Boolean]$_ExpandInViewsMenu = $false
|
||||||
|
|
||||||
|
ViewObjectBase()
|
||||||
|
{
|
||||||
|
if($this.GetType().Name -eq "ViewObjectBase") {
|
||||||
|
throw "Abstract class. Object cannot be created"
|
||||||
|
}
|
||||||
|
elseif($script:SingletonObjects.ContainsKey($this.GetType().Name) -eq $true) {
|
||||||
|
throw "Only one $($this.GetType().Name) object can be created"
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-SingletonObject $this.GetType().Name $this
|
||||||
|
|
||||||
|
([ViewObjectBase]$this).Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hidden Functions
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._SessionId = $script:SessionID
|
||||||
|
Add-ObjectProperty $this "ID" { $this._ID }
|
||||||
|
Add-ObjectProperty $this "Title" { $this._Title }
|
||||||
|
Add-ObjectProperty $this "Description" { $this._Description }
|
||||||
|
Add-ObjectProperty $this "Authentication" { $this._AuthenticaionObject }
|
||||||
|
Add-ObjectProperty $this "HideMenu" { $this._HideMenu }
|
||||||
|
Add-ObjectProperty $this "ViewPanel" { $this.GetViewPanel() }
|
||||||
|
Add-ObjectProperty $this "AddToMenu" { $this._AddToMenu }
|
||||||
|
Add-ObjectProperty $this "ExpandInViewsMenu" { $this._ExpandInViewsMenu }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Object[]]GetViewItems()
|
||||||
|
{
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
[Object]GetViewPanel()
|
||||||
|
{
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
Authenticate()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Object[]]OnItemChanged($SelectedItem)
|
||||||
|
{
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
OnDeactivating($NewActiveView)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
OnActivating($PreviousActiveView)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
OnActivated()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewItemBase
|
||||||
|
{
|
||||||
|
[String]$Id = ""
|
||||||
|
[String]$Name = ""
|
||||||
|
[String]$Icon = $null
|
||||||
|
|
||||||
|
ViewItemBase()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#ImportOrder 10
|
||||||
|
function Add-NativeClass
|
||||||
|
{
|
||||||
|
param(
|
||||||
|
[string]
|
||||||
|
$ClassName,
|
||||||
|
[string]
|
||||||
|
$ClassDefinition,
|
||||||
|
[String[]]
|
||||||
|
$AssembliesPartialName
|
||||||
|
)
|
||||||
|
# `-as [type]` yields a Type object or $null - never $true. Comparing a Type to
|
||||||
|
# $true coerces the right side to Type, which never matches, so this guard used
|
||||||
|
# to be dead: Add-Type ran on every re-import, the CLR rejected the
|
||||||
|
# already-loaded type, and the catch below logged "Failed to add type" every
|
||||||
|
# time. Harmless but it made a clean re-import look broken.
|
||||||
|
if ($ClassName -as [type]) { return }
|
||||||
|
|
||||||
|
[Reflection.Assembly]::LoadWithPartialName("System.ComponentModel") | Out-Null
|
||||||
|
foreach($Assembly in $AssembliesPartialName) {
|
||||||
|
[Reflection.Assembly]::LoadWithPartialName($Assembly) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Log "Add class $ClassName"
|
||||||
|
Add-Type -TypeDefinition $ClassDefinition -IgnoreWarnings -ErrorAction Stop #-ReferencedAssemblies @('System.ComponentModel')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-LogError "Failed to add type $($ClassName)" $_.Exception
|
||||||
|
Write-LogDebug "Definition:`n$ClassDefinition"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$classDef = @"
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
public class ObjectColumnInfo : System.ComponentModel.INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public string Property { get { return _property; } set { _property = value; NotifyPropertyChanged("Property"); } }
|
||||||
|
private string _property = null;
|
||||||
|
|
||||||
|
public string Header { get { return _header; } set { _header = value; NotifyPropertyChanged("Header"); } }
|
||||||
|
private string _header = null;
|
||||||
|
|
||||||
|
public ObjectColumnInfo(string Property, string Header)
|
||||||
|
{
|
||||||
|
_property = Property;
|
||||||
|
_header = Header;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
if(!String.IsNullOrEmpty(_header)) { return _header; }
|
||||||
|
return _property ?? String.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler PropertyChanged;
|
||||||
|
|
||||||
|
// This method is called by the Set accessor of each property.
|
||||||
|
// The CallerMemberName attribute that is applied to the optional propertyName
|
||||||
|
// parameter causes the property name of the caller to be substituted as an argument.
|
||||||
|
private void NotifyPropertyChanged(string propertyName = "")
|
||||||
|
{
|
||||||
|
if(PropertyChanged != null) { PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"@
|
||||||
|
|
||||||
|
Add-NativeClass -ClassName "ObjectColumnInfo" -ClassDefinition $classDef
|
||||||
|
|
||||||
|
$classDef = @"
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
public class NameValueObject : System.ComponentModel.INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public string Name { get { return _name; } set { _name = value; NotifyPropertyChanged("Name"); } }
|
||||||
|
private string _name = null;
|
||||||
|
|
||||||
|
public string Value { get { return _value; } set { _value = value; NotifyPropertyChanged("Value"); } }
|
||||||
|
private string _value = null;
|
||||||
|
|
||||||
|
public NameValueObject(string Name, string Value)
|
||||||
|
{
|
||||||
|
_name = Name;
|
||||||
|
_value = Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler PropertyChanged;
|
||||||
|
|
||||||
|
// This method is called by the Set accessor of each property.
|
||||||
|
// The CallerMemberName attribute that is applied to the optional propertyName
|
||||||
|
// parameter causes the property name of the caller to be substituted as an argument.
|
||||||
|
private void NotifyPropertyChanged(string propertyName = "")
|
||||||
|
{
|
||||||
|
if(PropertyChanged != null) { PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"@
|
||||||
|
|
||||||
|
Add-NativeClass -ClassName "NameValueObject" -ClassDefinition $classDef -AssembliesPartialName "System.ComponentModel"
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Entra Enrollment Group
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class EntraGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
EntraGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "Entra"
|
||||||
|
$this._Name = "Entra"
|
||||||
|
$this._Icon = "Entra"
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Entra Branding
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Entra Branding
|
||||||
|
class EntraBrandingType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
EntraBrandingType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "EntraGroup")
|
||||||
|
$this._PolicyName = "Entra Branding"
|
||||||
|
$this._ID = "AzureBranding"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._HasModified = $false
|
||||||
|
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||||
|
$this._SupportsNameFilter = $false
|
||||||
|
$this._API = "organization/%OrganizationId%/branding/localizations"
|
||||||
|
$this._Permissions = @("Organization.ReadWrite.All")
|
||||||
|
$this._NameProperty = "Id"
|
||||||
|
$this._Icon = "Branding"
|
||||||
|
#$this._ShowButtons = @("Export","View")
|
||||||
|
$this._ExpandAssignments = $false
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
$this._SkipAddIDOnFileName = $true
|
||||||
|
$this._TopItems = 0
|
||||||
|
# Graph rejects `?$top=...` on /organization/<tid>/branding/localizations
|
||||||
|
# with HTTP 400. Mirrors `SupportsPageSize=$false` in the OLD project's
|
||||||
|
# AzureBranding type definition; without it, bulk export's default of
|
||||||
|
# `$top=1000` makes the listing call fail and the type silently produces
|
||||||
|
# zero files.
|
||||||
|
$this._HasPageSizeSupport = $false
|
||||||
|
$this._ObjectClass = "EntraBrandingObject"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
$ret = @{}
|
||||||
|
|
||||||
|
# ToDo: Verify functionallity for import
|
||||||
|
|
||||||
|
Remove-Property $PolicyObject.JsonObject "@odata.Type"
|
||||||
|
|
||||||
|
if($PolicyObject.JsonObject.Id -eq "0")
|
||||||
|
{
|
||||||
|
$ret.Add("Method","PATCH") # Default profile always exists so update it
|
||||||
|
$ret.Add("API", "organization/%OrganizationId%/branding")
|
||||||
|
}
|
||||||
|
# This is NOT what the documentation says
|
||||||
|
# Documentation says to use Content-Language
|
||||||
|
# Only place the documentation states to use Accept-Language is for Get operation
|
||||||
|
# https://docs.microsoft.com/en-us/graph/api/organizationalbrandingproperties-get?view=graph-rest-beta&tabs=http#request-headers
|
||||||
|
$ret.Add("AdditionalHeaders", @{ "Accept-Language" = $PolicyObject.JsonObject.Id })
|
||||||
|
|
||||||
|
return $ret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class EntraBrandingObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
|
||||||
|
Hidden [String]$_Language = $null
|
||||||
|
|
||||||
|
EntraBrandingObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
EntraBrandingObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
if($this.Object.id -eq "0")
|
||||||
|
{
|
||||||
|
$this._Language = "Default"
|
||||||
|
}
|
||||||
|
elseif($this.Object.id)
|
||||||
|
{
|
||||||
|
$this._Language = ([cultureinfo]::GetCultureInfo($this.Object.id)).DisplayName
|
||||||
|
}
|
||||||
|
Add-ObjectProperty $this "Language" { $this._Language }
|
||||||
|
|
||||||
|
$this._PolicyType = (Get-SingletonObject "EntraBrandingType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Terms and Condition
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Terms and Condition
|
||||||
|
class TermsAndConditionType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
TermsAndConditionType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "EntraGroup")
|
||||||
|
$this._APITitle = "Terms and Conditions"
|
||||||
|
$this._PolicyName = "Terms and Condition"
|
||||||
|
$this._ID = "TermsAndConditions"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._API = "deviceManagement/termsAndConditions"
|
||||||
|
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||||
|
$this._ExpandAssignments = $false
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._ObjectClass = "TermsAndConditionObject"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
return (Add-GraphAssignmentsToObject $PolicyObject $SourceObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||||
|
{
|
||||||
|
# Hydration already fanned out /assignments via the
|
||||||
|
# _HasSubResourceBatch contract on TermsAndConditionObject — skip
|
||||||
|
# the per-policy round-trip the helper would otherwise make.
|
||||||
|
if($script:_skipDirectGet -eq $true) { return }
|
||||||
|
Add-GraphAssignmentsToExportFile $PolicyObject $PathToFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class TermsAndConditionObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
TermsAndConditionObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
TermsAndConditionObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "TermsAndConditionType")
|
||||||
|
|
||||||
|
# Opt into the bulk-export sub-resource batching contract so the
|
||||||
|
# /assignments side-channel gets fanned out via $batch instead of
|
||||||
|
# one synchronous round-trip per policy in PostExportCommand.
|
||||||
|
$this._HasSubResourceBatch = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||||
|
{
|
||||||
|
if($Phase -ne 1) { return @() }
|
||||||
|
return @([PSCustomObject]@{
|
||||||
|
Key = 'assignments'
|
||||||
|
Url = "$($this._PolicyType.API)/$($this.Id)/assignments"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||||
|
{
|
||||||
|
if($Phase -eq 1 -and $Key -eq 'assignments') {
|
||||||
|
# Comma-prefix forces an array reference through the if-expression —
|
||||||
|
# without it, PowerShell unwraps a single-element @() on assignment
|
||||||
|
# and ConvertTo-Json then emits a bare object instead of [{...}].
|
||||||
|
$assignments = if($Body -and $Body.value) { ,@($Body.value) } else { ,@() }
|
||||||
|
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'assignments' -Value $assignments -Force
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# AppleEnrollmentTypeObject lives in IntuneAppleClasses.ps1 — was duplicated here,
|
||||||
|
# which both confused PowerShell's parser ("The member 'AppleEnrollmentTypeObject'
|
||||||
|
# is already defined" when the class files are concatenated for static analysis)
|
||||||
|
# and risked a non-deterministic resolution depending on which file's definition
|
||||||
|
# the runtime committed last. Single source of truth in IntuneAppleClasses.ps1
|
||||||
|
# (loaded via the same ImportOrder = 220) is sufficient.
|
||||||
|
|
||||||
|
#endregion
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#ImportOrder 24
|
||||||
|
|
||||||
|
# Canonical, provider-agnostic authentication token descriptor.
|
||||||
|
#
|
||||||
|
# One typed shape used everywhere a token/identity is surfaced: the auth-event
|
||||||
|
# payloads (AuthenticatedNewToken / AuthenticationTokenRefresh /
|
||||||
|
# AuthenticationUserDisconnected), every provider's GetUserInfo() return value,
|
||||||
|
# the central token registry in Internal/AuthenticationCore.ps1, and the public
|
||||||
|
# Get-IMAuthToken function.
|
||||||
|
#
|
||||||
|
# Providers populate everything they can see from the token itself (Provider,
|
||||||
|
# TenantId, TenantName, Cloud, Account/UPN/UserId, AppId/AppName, AuthType,
|
||||||
|
# ExpiresOn). The registry overlays the two fields a provider cannot know on its
|
||||||
|
# own: the GLOBAL TokenId (registry-allocated, unique across providers) and
|
||||||
|
# IsDefault (derived from the registry's single default-id).
|
||||||
|
#
|
||||||
|
# ImportOrder 24: must parse before AuthenticationProvider.ps1 (#ImportOrder 25),
|
||||||
|
# whose GetUserInfo signature returns [IMAuthToken], and the concrete providers
|
||||||
|
# (26/27). Core primitives load at 1/10, so 24 is free and safely after them.
|
||||||
|
|
||||||
|
class IMAuthToken {
|
||||||
|
[int] $TokenId # GLOBAL id (registry-allocated); 0 = unassigned
|
||||||
|
[string] $Provider # owning provider Id: "MSAL" | "OAuth" | "MgGraph"
|
||||||
|
[string] $TenantId
|
||||||
|
[string] $TenantName
|
||||||
|
[string] $Cloud # "Public" | "USGov" | "USGovDOD" | "China"
|
||||||
|
[string] $Account # display name (UPN, or app name for app-only)
|
||||||
|
[string] $UPN
|
||||||
|
[string] $UserId
|
||||||
|
[string] $AppId
|
||||||
|
[string] $AppName
|
||||||
|
[string] $AuthType # Interactive | ClientCredential | BYO | ManagedIdentity | WorkloadFederation | Password
|
||||||
|
[bool] $IsDefault
|
||||||
|
[Nullable[datetime]] $ExpiresOn
|
||||||
|
|
||||||
|
[string] ToString() {
|
||||||
|
$tenant = if ($this.TenantName) { $this.TenantName } elseif ($this.TenantId) { $this.TenantId } else { '?' }
|
||||||
|
return "$($this.Provider)/$tenant ($($this.TokenId))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class AppConfigurationGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
AppConfigurationGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "AppConfiguration"
|
||||||
|
$this._Name = "App configuration policies"
|
||||||
|
$this._Icon = "AppConfiguration"
|
||||||
|
$this._ExtraColumns = @("EnrolmentType=Enrolment type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# App Configuration (App)
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region App Configuration (App)
|
||||||
|
class AppConfigurationManagedAppType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
AppConfigurationManagedAppType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "AppConfigurationGroup")
|
||||||
|
$this._PolicyName = "App configuration (App)"
|
||||||
|
$this._ID = "AppConfigurationManagedApp"
|
||||||
|
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||||
|
$this._SupportsNameFilter = $false
|
||||||
|
$this._API = "deviceAppManagement/targetedManagedAppConfigurations"
|
||||||
|
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||||
|
$this._Dependencies = @("Applications")
|
||||||
|
$this._ObjectClass = "AppConfigurationManagedAppObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
# CheckPolicy authoritatively matches this type by @odata.type (plus the base
|
||||||
|
# @odata.id fallback), so a rejection is real - skip the folder-trust fallback.
|
||||||
|
$this._StrictODataTypeCheck = $true
|
||||||
|
$this._Icon = "AppConfiguration"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
# targetedManagedAppConfiguration is surfaced by the polymorphic
|
||||||
|
# managedAppPolicies collection alongside App Protection variants, so match it
|
||||||
|
# explicitly by @odata.type. This also lets a file object (which carries only
|
||||||
|
# @odata.type, no top-level @odata.id) resolve to this type; the base
|
||||||
|
# @odata.id-based CheckPolicy would reject it.
|
||||||
|
if($PolicyObject.'@odata.type' -eq '#microsoft.graph.targetedManagedAppConfiguration')
|
||||||
|
{
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fall back to the base @odata.id matcher (deviceAppManagement/targetedManagedAppConfigurations)
|
||||||
|
# for objects that carry an id but no top-level @odata.type.
|
||||||
|
return ([IntunePolicyTypeBase]$this).CheckPolicy($PolicyObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
# apps is a navigation property set after create via targetApps
|
||||||
|
# (PostImportCommand); strip it, then POST to the type API
|
||||||
|
# (deviceAppManagement/targetedManagedAppConfigurations).
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
if($SourceObject.Object.Apps) {
|
||||||
|
# No "@odata.type" on the created object so reload new object
|
||||||
|
#$newObject = (Invoke-MSGraphAPI "$($objectType.API)?`$filter=id eq '$($obj.Id)'").Value
|
||||||
|
$newObject = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)" -TokenId $PolicyObject._TokenID
|
||||||
|
if($newObject)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$apps = [PSCustomObject]@{
|
||||||
|
appGroupType = $PolicyObject.Object.appGroupType
|
||||||
|
apps = @($SourceObject.Object.Apps)
|
||||||
|
}
|
||||||
|
$json = $apps | ConvertTo-Json -Depth 20
|
||||||
|
|
||||||
|
Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $PolicyObject._TokenID | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# The policy was created; only the targetApps association failed. Keep
|
||||||
|
# going but make the partial import visible instead of swallowing it.
|
||||||
|
Write-LogError "Failed to assign target apps to imported App configuration policy '$($PolicyObject.displayName)' ($($PolicyObject.Id)) via $($this.API)/$($PolicyObject.Id)/targetApps" $_.Exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$ExistingObject)
|
||||||
|
{
|
||||||
|
# apps is a navigation property set via targetApps, not inline. Re-post
|
||||||
|
# apps to the type API, strip them from the PATCH body, then PATCH the
|
||||||
|
# type API (deviceAppManagement/targetedManagedAppConfigurations).
|
||||||
|
if($PolicyObject.JsonObject.apps)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$apps = [PSCustomObject]@{
|
||||||
|
appGroupType = $PolicyObject.JsonObject.appGroupType
|
||||||
|
apps = @($PolicyObject.JsonObject.apps)
|
||||||
|
}
|
||||||
|
$json = $apps | ConvertTo-Json -Depth 20
|
||||||
|
Invoke-MSGraphAPI -Url "$($this.API)/$($ExistingObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $ExistingObject._TokenID | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# The policy PATCH still proceeds; surface the failed targetApps update.
|
||||||
|
Write-LogError "Failed to update target apps for App configuration policy '$($ExistingObject.displayName)' ($($ExistingObject.Id)) via $($this.API)/$($ExistingObject.Id)/targetApps" $_.Exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||||
|
# assignments is a navigation property (managed via the /assign action),
|
||||||
|
# not inline-PATCHable.
|
||||||
|
Remove-Property $PolicyObject.JsonObject "assignments"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class AppConfigurationManagedAppObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
Hidden [string]$_objectClass = $null
|
||||||
|
|
||||||
|
AppConfigurationManagedAppObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
AppConfigurationManagedAppObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "AppConfigurationManagedAppType")
|
||||||
|
|
||||||
|
Add-ObjectProperty $this "EnrolmentType" { "Managed apps" }
|
||||||
|
|
||||||
|
Get-AppConfigurationClass $this
|
||||||
|
|
||||||
|
# Targeted-app resolution runs through the sub-resource contract; see the
|
||||||
|
# Get-/Add-/Build-AppConfigTargetApp* helpers below.
|
||||||
|
$this._HasSubResourceBatch = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||||
|
{
|
||||||
|
if($Phase -ne 1) { return @() }
|
||||||
|
return (Get-AppConfigTargetAppRequests $this)
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||||
|
{
|
||||||
|
if($Phase -eq 1) { Add-AppConfigTargetAppResult $Key $Body }
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] FinalizeSubResources()
|
||||||
|
{
|
||||||
|
Build-AppConfigTargetAppRefs $this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# App Configuration (Device)
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region App Configuration (Device)
|
||||||
|
class AppConfigurationManagedDeviceType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
AppConfigurationManagedDeviceType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "AppConfigurationGroup")
|
||||||
|
$this._PolicyName = "App configuration (Device)"
|
||||||
|
$this._ID = "AppConfigurationManagedDevice"
|
||||||
|
$this._API = "deviceAppManagement/mobileAppConfigurations"
|
||||||
|
$this._QueryList = "?`$filter=microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig%20eq%20false%20or%20isof(%27microsoft.graph.androidManagedStoreAppConfiguration%27)%20eq%20false"
|
||||||
|
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||||
|
$this._Dependencies = @("Applications")
|
||||||
|
$this._ObjectClass = "AppConfigurationManagedDeviceObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._Icon = "AppConfiguration"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
return (@{"API"="deviceAppManagement/mobileAppConfigurations/$($PolicyObject.Id)/microsoft.graph.managedDeviceMobileAppConfiguration/assign"})
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
Import-AppConfigurationTargetedApps $PolicyObject
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class AppConfigurationManagedDeviceObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
AppConfigurationManagedDeviceObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
AppConfigurationManagedDeviceObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "AppConfigurationManagedDeviceType")
|
||||||
|
|
||||||
|
Add-ObjectProperty $this "EnrolmentType" { "Managed devices" }
|
||||||
|
|
||||||
|
# Targeted-app resolution runs through the sub-resource contract; see the
|
||||||
|
# Get-/Add-/Build-AppConfigTargetApp* helpers.
|
||||||
|
$this._HasSubResourceBatch = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||||
|
{
|
||||||
|
if($Phase -ne 1) { return @() }
|
||||||
|
return (Get-AppConfigTargetAppRequests $this)
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||||
|
{
|
||||||
|
if($Phase -eq 1) { Add-AppConfigTargetAppResult $Key $Body }
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] FinalizeSubResources()
|
||||||
|
{
|
||||||
|
Build-AppConfigTargetAppRefs $this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Generic Functions
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
function Get-AppConfigurationClass
|
||||||
|
{
|
||||||
|
param($Policy)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tmp = $Policy.Object."@odata.type".Split('.')[-1]
|
||||||
|
$Policy._objectClass = Get-GraphObjectClassName $tmp
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
if($null -eq $Policy._objectClass) {
|
||||||
|
Write-Log "Could not get class name for $($Policy.Name) ($($Policy.Object."@odata.type"))" 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AppConfigurationFullObject
|
||||||
|
{
|
||||||
|
param($Policy)
|
||||||
|
|
||||||
|
if(-not $Policy.Object."@odata.type" -or -not $Policy._objectClass) { return $false }
|
||||||
|
|
||||||
|
$expand = $null
|
||||||
|
if($Policy._objectClass -eq "windowsInformationProtectionPolicies")
|
||||||
|
{
|
||||||
|
$expand = "?`$expand=protectedAppLockerFiles,exemptAppLockerFiles"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$url = $Policy.GetObjectURL()
|
||||||
|
|
||||||
|
$tmpArr = $url.Split("?")
|
||||||
|
if($tmpArr.Length -gt 1) {
|
||||||
|
$expand = "?" + $tmpArr[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullObject = (Invoke-MSGraphAPI -Url "deviceAppManagement/$($Policy._objectClass)/$($Policy.Id)$expand" -TokenId $Policy._TokenId)
|
||||||
|
if($fullObject)
|
||||||
|
{
|
||||||
|
$Policy.JsonObject = $fullObject
|
||||||
|
$Policy._IsFullObject = $true
|
||||||
|
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Targeted-app resolution for AppConfiguration policies.
|
||||||
|
#
|
||||||
|
# The policy body lists app IDs in targetedMobileApps; cross-tenant export needs
|
||||||
|
# each app's displayName + @odata.type to re-map them on import into another
|
||||||
|
# tenant (#CustomRefTargetedApps). The mobileApps/<id> lookup is owned ONLY by
|
||||||
|
# Get-AppConfigTargetAppRequests below — the sub-resource contract on the
|
||||||
|
# AppConfiguration*Object classes drives the fetch (coalesced across policies by
|
||||||
|
# Invoke-PolicySubresourceFetch's URL de-dup), caches results, then builds the
|
||||||
|
# ref string. Previously this was duplicated in Sync-BulkExportAppConfigurationTargetApps
|
||||||
|
# (Internal/PolicyHydrateExtras.ps1).
|
||||||
|
|
||||||
|
# Process-wide cache: appId -> app body (or $null for a 404/miss). Shared across
|
||||||
|
# every AppConfig policy in a hydrate run so the same app is fetched once.
|
||||||
|
function Get-AppConfigTargetAppCache
|
||||||
|
{
|
||||||
|
if($null -eq $script:_appConfigTargetAppCache) { $script:_appConfigTargetAppCache = @{} }
|
||||||
|
return $script:_appConfigTargetAppCache
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only these polymorphic AppConfig @odata.types carry targetedMobileApps that
|
||||||
|
# need tenant-specific remapping. (androidManagedAppProtection is listed for
|
||||||
|
# parity with the legacy filter; App Protection policies use `apps`, not
|
||||||
|
# `targetedMobileApps`, so they never actually match.)
|
||||||
|
function Test-AppConfigHasTargetApps
|
||||||
|
{
|
||||||
|
param($Policy)
|
||||||
|
return ($Policy.JsonObject.'@OData.Type' -in @(
|
||||||
|
'#microsoft.graph.androidManagedAppProtection',
|
||||||
|
'#microsoft.graph.androidForWorkMobileAppConfiguration',
|
||||||
|
'#microsoft.graph.androidManagedStoreAppConfiguration',
|
||||||
|
'#microsoft.graph.iosMobileAppConfiguration'
|
||||||
|
) -and @($Policy.JsonObject.targetedMobileApps).Count -gt 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sub-resource requests for every targeted app not already cached. The
|
||||||
|
# deviceAppManagement/mobileApps/<id> URL lives ONLY here.
|
||||||
|
function Get-AppConfigTargetAppRequests
|
||||||
|
{
|
||||||
|
param($Policy)
|
||||||
|
if(-not (Test-AppConfigHasTargetApps $Policy)) { return @() }
|
||||||
|
$cache = Get-AppConfigTargetAppCache
|
||||||
|
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
foreach($appId in @($Policy.JsonObject.targetedMobileApps)) {
|
||||||
|
if(-not $appId -or $cache.ContainsKey($appId)) { continue }
|
||||||
|
[void]$reqs.Add([PSCustomObject]@{
|
||||||
|
Key = "targetApp_$appId"
|
||||||
|
Url = "deviceAppManagement/mobileApps/$appId"
|
||||||
|
Headers = @{ Accept = 'application/json;odata.metadata=minimal' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return $reqs.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache one targeted-app sub-resource response. Stores $null for misses so 404s
|
||||||
|
# aren't re-requested by a later policy in the same run.
|
||||||
|
function Add-AppConfigTargetAppResult
|
||||||
|
{
|
||||||
|
param([string]$Key, $Body)
|
||||||
|
if($Key -notlike 'targetApp_*') { return }
|
||||||
|
$appId = $Key.Substring('targetApp_'.Length)
|
||||||
|
(Get-AppConfigTargetAppCache)[$appId] = $Body
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build #CustomRefTargetedApps from the cached app bodies (finalize step).
|
||||||
|
function Build-AppConfigTargetAppRefs
|
||||||
|
{
|
||||||
|
param($Policy)
|
||||||
|
if(-not (Test-AppConfigHasTargetApps $Policy)) { return }
|
||||||
|
$cache = Get-AppConfigTargetAppCache
|
||||||
|
$targetedApps = @()
|
||||||
|
foreach($appId in @($Policy.JsonObject.targetedMobileApps)) {
|
||||||
|
$appObj = $cache[$appId]
|
||||||
|
if($appObj) {
|
||||||
|
Write-Log "Add target app info $($appObj.displayName) ($($appObj.Id)) of type $($appObj.'@OData.Type')"
|
||||||
|
$targetedApps += $appObj.displayName + '|!|' + $appObj.Id + '|!|' + $appObj.'@OData.Type'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log "No app found with id $appId" 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($targetedApps.Count -gt 0) {
|
||||||
|
Add-Member -InputObject $Policy.JsonObject -MemberType NoteProperty -Name '#CustomRefTargetedApps' -Value ($targetedApps -join '|*|') -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Import-AppConfigurationTargetedApps
|
||||||
|
{
|
||||||
|
param($Policy)
|
||||||
|
|
||||||
|
if($Policy.JsonObject."#CustomRefTargetedApps" -and $Policy.JsonObject.targetedMobileApps)
|
||||||
|
{
|
||||||
|
Write-Log "Adding app targets for $($Policy.JsonObject.displayName)"
|
||||||
|
|
||||||
|
$targetedAppsInfo = $Policy.JsonObject."#CustomRefTargetedApps"
|
||||||
|
|
||||||
|
$translatedTargetedApps = @()
|
||||||
|
|
||||||
|
if($targetedAppsInfo)
|
||||||
|
{
|
||||||
|
foreach($targetedApp in ($targetedAppsInfo -split "[|][*][|]"))
|
||||||
|
{
|
||||||
|
$appName, $appId, $appType = $targetedApp -split "[|][!][|]"
|
||||||
|
if(-not $appName -or -not $appId)
|
||||||
|
{
|
||||||
|
Write-Log "App Name and Id is missing in string: $appApp" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tmpApps = (Invoke-MSGraphAPI -Url "/deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $Policy._TokenId).value
|
||||||
|
if(-not $tmpApps)
|
||||||
|
{
|
||||||
|
Write-Log "No application found with name $appName. $appId will not be translated and added to target list" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tmpApp = $tmpApps | Where-Object '@OData.Type' -eq $appType
|
||||||
|
if(-not $tmpApp)
|
||||||
|
{
|
||||||
|
Write-Log "No $appName application found of type $appType. $appId will not be translated and added to target list" 2
|
||||||
|
}
|
||||||
|
elseif(($tmpApp | Measure-Object).Count -gt 1) {
|
||||||
|
Write-Log "$(($tmpApp | Measure-Object).Count) applications found with name '$appName' of type $appType. $appId will not be translated and added to target list" 2
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log "Found '$appName' with id $($tmpApp.Id) ($appType)"
|
||||||
|
$translatedTargetedApps += $tmpApp.Id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($translatedTargetedApps.Count -gt 0) {
|
||||||
|
Write-Log "Updating translated targeted apps"
|
||||||
|
$Policy.JsonObject.targetedMobileApps = $translatedTargetedApps
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log "Could not find targeted apps in the evnironment. Verify that they are added. Policy import might fail" 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class AppProtectionGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
AppProtectionGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "AppProtection"
|
||||||
|
$this._Name = "App protection policies"
|
||||||
|
$this._Icon = "AppProtection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# App Protection
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region App Protection
|
||||||
|
class AppProtectionType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
AppProtectionType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "AppProtectionGroup")
|
||||||
|
$this._PolicyName = "App protection policy"
|
||||||
|
$this._ID = "AppProtection"
|
||||||
|
$this._SubTypeColumn = "ManagementType=Management type"
|
||||||
|
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||||
|
$this._SupportsNameFilter = $false
|
||||||
|
$this._API = "deviceAppManagement/managedAppPolicies"
|
||||||
|
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||||
|
$this._Dependencies = @("Applications")
|
||||||
|
$this._ObjectClass = "AppProtectionPolicyObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
# Assignments are fetched per platform collection - see
|
||||||
|
# GetAssignmentsBaseURL below. _AssignmentsViaExpand stays $false:
|
||||||
|
# once the URL targets a concrete subtype the plain navigation GET
|
||||||
|
# works, and it returns just the assignments instead of the whole policy.
|
||||||
|
$this._PropertiesToRemove = @('exemptAppLockerFiles')
|
||||||
|
$this._PropertiesToRemoveForUpdate = @("protectedAppLockerFiles","version") # ToDo: !!! Add support for protectedAppLockerFiles?
|
||||||
|
$this._VerifyObject = $true
|
||||||
|
# CheckPolicy is a complete @odata.type matcher (the managedAppPolicies allowlist),
|
||||||
|
# so a rejection is authoritative - do not let the folder-trust fallback rescue a
|
||||||
|
# foreign object misplaced in this type's export folder.
|
||||||
|
$this._StrictODataTypeCheck = $true
|
||||||
|
$this._Icon = "AppConfiguration"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# managedAppPolicies is Collection(managedAppPolicy), and managedAppPolicy
|
||||||
|
# declares no navigation properties - so both {API}/{id}/assignments and
|
||||||
|
# {API}/{id}?$expand=assignments return 400 ("Could not find a property named
|
||||||
|
# 'assignments' on type 'microsoft.graph.managedAppPolicy'"). Every concrete
|
||||||
|
# subtype inherits `assignments`, so route through the per-platform collection
|
||||||
|
# (_objectClass, set in the object's constructor via Get-AppConfigurationClass).
|
||||||
|
# defaultManagedAppProtection is the exception - it has no assignments at all.
|
||||||
|
[String]GetAssignmentsBaseURL([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if(-not $PolicyObject._objectClass) { return $this._API }
|
||||||
|
if($PolicyObject._objectClass -eq "defaultManagedAppProtections") { return $null }
|
||||||
|
|
||||||
|
return "deviceAppManagement/$($PolicyObject._objectClass)"
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
# apps is a navigation property set later via targetApps (PostImportCommand),
|
||||||
|
# not an inline body property - strip it from the POST body.
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||||
|
|
||||||
|
# The polymorphic managedAppPolicies collection (used for listing) rejects
|
||||||
|
# POST, so import must target the per-platform collection. _objectClass is
|
||||||
|
# the metadata-derived endpoint segment (e.g. iosManagedAppProtections),
|
||||||
|
# set on the object at construction via Get-AppConfigurationClass.
|
||||||
|
if($PolicyObject._objectClass)
|
||||||
|
{
|
||||||
|
return @{"API"="deviceAppManagement/$($PolicyObject._objectClass)"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (@{})
|
||||||
|
}
|
||||||
|
|
||||||
|
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
if($SourceObject.Object.Apps) {
|
||||||
|
# No "@odata.type" on the created object so reload new object
|
||||||
|
#$newObject = (Invoke-MSGraphAPI "$($objectType.API)?`$filter=id eq '$($obj.Id)'").Value
|
||||||
|
$newObject = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)" -TokenId $PolicyObject._TokenID
|
||||||
|
if($newObject)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$apps = [PSCustomObject]@{
|
||||||
|
appGroupType = $PolicyObject.Object.appGroupType
|
||||||
|
apps = @($SourceObject.Object.Apps)
|
||||||
|
}
|
||||||
|
$json = $apps | ConvertTo-Json -Depth 20
|
||||||
|
|
||||||
|
# Created object carries no @odata.type; use the source object's
|
||||||
|
# metadata-derived endpoint segment (_objectClass).
|
||||||
|
if($SourceObject._objectClass)
|
||||||
|
{
|
||||||
|
Invoke-MSGraphAPI -Url "deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $PolicyObject._TokenID | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# The policy was created; only the targetApps association failed. Keep
|
||||||
|
# going but make the partial import visible instead of swallowing it.
|
||||||
|
Write-LogError "Failed to assign target apps to imported App protection policy '$($PolicyObject.displayName)' ($($PolicyObject.Id)) via deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/targetApps" $_.Exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
# /assign is bound to the concrete platform subtype; the polymorphic
|
||||||
|
# managedAppPolicies collection returns 400 for the assign action.
|
||||||
|
if($SourceObject._objectClass)
|
||||||
|
{
|
||||||
|
return @{"API"="deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/assign"}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$ExistingObject)
|
||||||
|
{
|
||||||
|
# managedAppPolicies rejects PATCH, and apps is a navigation property set
|
||||||
|
# via targetApps rather than inline. Re-post apps to the per-platform
|
||||||
|
# endpoint, strip them from the PATCH body, then PATCH that endpoint.
|
||||||
|
#
|
||||||
|
# Routing note: an imported object's POST response carries no
|
||||||
|
# @odata.type, so ITS _objectClass can be null - the update object came
|
||||||
|
# from a file that always has the type, so prefer that one.
|
||||||
|
if(-not $ExistingObject._objectClass -and $PolicyObject._objectClass)
|
||||||
|
{
|
||||||
|
$ExistingObject._objectClass = $PolicyObject._objectClass
|
||||||
|
}
|
||||||
|
|
||||||
|
if($PolicyObject.JsonObject.apps -and $ExistingObject._objectClass)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$apps = [PSCustomObject]@{
|
||||||
|
appGroupType = $PolicyObject.JsonObject.appGroupType
|
||||||
|
apps = @($PolicyObject.JsonObject.apps)
|
||||||
|
}
|
||||||
|
$json = $apps | ConvertTo-Json -Depth 20
|
||||||
|
Invoke-MSGraphAPI -Url "deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $ExistingObject._TokenID | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# The policy PATCH still proceeds; surface the failed targetApps update.
|
||||||
|
Write-LogError "Failed to update target apps for App protection policy '$($ExistingObject.displayName)' ($($ExistingObject.Id)) via deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)/targetApps" $_.Exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
|
||||||
|
# assignments is a navigation property (managed via the /assign action),
|
||||||
|
# not inline-PATCHable - PATCHing it 400s on the platform entity type.
|
||||||
|
Remove-Property $PolicyObject.JsonObject "assignments"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
|
||||||
|
|
||||||
|
if($ExistingObject._objectClass)
|
||||||
|
{
|
||||||
|
return @{"API"="deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)"}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
# App Protection owns the polymorphic deviceAppManagement/managedAppPolicies
|
||||||
|
# collection: the per-platform *ManagedAppProtection variants (ios / android /
|
||||||
|
# windows / default) plus the two Windows Information Protection policy types.
|
||||||
|
# An explicit allowlist is required: file objects carry only @odata.type (no
|
||||||
|
# top-level @odata.id), so this runs as the file->type discriminator. A previous
|
||||||
|
# "accept everything except targetedManagedAppConfiguration" greedily claimed
|
||||||
|
# unrelated policy types (Compliance, CA, etc.) when resolving from an export
|
||||||
|
# folder. targetedManagedAppConfiguration is App Config, not App Protection
|
||||||
|
# (see AppConfigurationManagedAppType.CheckPolicy).
|
||||||
|
$odata = [string]$PolicyObject.'@odata.type'
|
||||||
|
if($odata -match 'ManagedAppProtection$' -or
|
||||||
|
$odata -eq '#microsoft.graph.mdmWindowsInformationProtectionPolicy' -or
|
||||||
|
$odata -eq '#microsoft.graph.windowsInformationProtectionPolicy')
|
||||||
|
{
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class AppProtectionPolicyObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
Hidden [string]$_objectClass = $null
|
||||||
|
Hidden [string]$_managemntType = $null
|
||||||
|
|
||||||
|
AppProtectionPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
AppProtectionPolicyObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "AppProtectionType")
|
||||||
|
|
||||||
|
if($this.Object."@odata.type" -eq "#microsoft.graph.mdmWindowsInformationProtectionPolicy") {
|
||||||
|
$this._managemntType = "With enrollment"
|
||||||
|
}
|
||||||
|
elseif($this.Object."@odata.type" -eq "#microsoft.graph.windowsInformationProtectionPolicy") {
|
||||||
|
$this._managemntType = "Without enrollment"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$this._managemntType = "All app types"
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-ObjectProperty $this "ManagementType" { $this._managemntType }
|
||||||
|
|
||||||
|
Get-AppConfigurationClass $this
|
||||||
|
|
||||||
|
if($this.JsonObject."@odata.type" -eq "#microsoft.graph.iosManagedAppProtection") {
|
||||||
|
$platformName = Get-LanguageString "AppProtection.iOSPlatformLabel"
|
||||||
|
if($platformName) {
|
||||||
|
#$this._PlatformName = $platformName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Apple Enrollment Group
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class AppleEnrollmentGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
AppleEnrollmentGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "AppleEnrollment"
|
||||||
|
$this._Name = "Apple Enrollment"
|
||||||
|
$this._Icon = "AppleEnrollmentTypes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Apple Enrollment Types
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Apple Enrollment Types
|
||||||
|
class AppleEnrollmentType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
AppleEnrollmentType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
# _PolicyGroup must be AppleEnrollmentGroup (the group declared at the top of
|
||||||
|
# this file). The original wiring pointed at AppleUpdateGroup, which is the
|
||||||
|
# software-update group — wrong taxonomy.
|
||||||
|
# _ObjectClass was never set, so IntunePolicyTypeBase.GetObject took the
|
||||||
|
# "Object class is missing" branch and dropped every returned row, surfacing
|
||||||
|
# as 'no Apple Enrollment Types objects matched' even though the API returned
|
||||||
|
# data. Wire it up to AppleEnrollmentTypeObject (defined in this same file).
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "AppleEnrollmentGroup")
|
||||||
|
$this._APITitle = "Apple Enrollment Types"
|
||||||
|
$this._PolicyName = "Apple Enrollment Type"
|
||||||
|
$this._ID = "AppleEnrollmentTypes"
|
||||||
|
$this._API = "deviceManagement/appleUserInitiatedEnrollmentProfiles"
|
||||||
|
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||||
|
$this._ObjectClass = "AppleEnrollmentTypeObject"
|
||||||
|
$this._PropertiesToRemoveForUpdate = @('platform')
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class AppleEnrollmentTypeObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
AppleEnrollmentTypeObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
AppleEnrollmentTypeObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "AppleEnrollmentType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Apple Update Group
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class AppleUpdateGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
AppleUpdateGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "AppleUpdates"
|
||||||
|
$this._Name = "Apple updates"
|
||||||
|
$this._Icon = "AppleUpdates"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# iOS/iPadOS Updates
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region iOS/iPadOS Updates
|
||||||
|
class iOSiPadOSPolicyType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
iOSiPadOSPolicyType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "AppleUpdateGroup")
|
||||||
|
$this._PolicyName = "iOS/iPadOS update policies"
|
||||||
|
$this._ID = "iOSiPadOSUpdatePolicies"
|
||||||
|
$this._API = "deviceManagement/deviceConfigurations"
|
||||||
|
$this._QueryList = "?`$filter=isof(%27microsoft.graph.iosUpdateConfiguration%27)"
|
||||||
|
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||||
|
$this._ObjectClass = "iOSiPadOSPolicyObject"
|
||||||
|
$this._Icon = "iOSUpdates"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
|
||||||
|
$this._PolicyTypeOrder = 90
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.iosUpdateConfiguration") { return $false }
|
||||||
|
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class iOSiPadOSPolicyObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
iOSiPadOSPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
iOSiPadOSPolicyObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "iOSiPadOSPolicyType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# MacOS Updates
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region MacOS Updates
|
||||||
|
class macOSPolicyType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
macOSPolicyType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "AppleUpdateGroup")
|
||||||
|
$this._PolicyName = "macOS update policies"
|
||||||
|
$this._ID = "macOSUpdatePolicies"
|
||||||
|
$this._API = "deviceManagement/deviceConfigurations"
|
||||||
|
$this._QueryList = "?`$filter=isof(%27microsoft.graph.macOSSoftwareUpdateConfiguration%27)"
|
||||||
|
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||||
|
$this._ObjectClass = "macOSPolicyObject"
|
||||||
|
$this._Icon = "MacOSUpdates"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
|
||||||
|
$this._PolicyTypeOrder = 90
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.macOSSoftwareUpdateConfiguration") { return $false }
|
||||||
|
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class macOSPolicyObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
macOSPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
macOSPolicyObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "macOSPolicyType")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Applications Group
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class ApplicationsGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
ApplicationsGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "Applications"
|
||||||
|
$this._Name = "Applications"
|
||||||
|
$this._Icon = "Applications"
|
||||||
|
# Type (Win32, iOS store, ...) is the discriminator here; Policy type would read
|
||||||
|
# "Application" on every row but the iOS provisioning profiles.
|
||||||
|
$this._ExtraColumns = @("ApplicationType=Type")
|
||||||
|
$this._ShowPolicyTypeColumn = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Application Type
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Application Type
|
||||||
|
class ApplicationType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
ApplicationType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ApplicationsGroup")
|
||||||
|
$this._APITitle = "Applications"
|
||||||
|
$this._PolicyName = "Applications"
|
||||||
|
$this._ID = "Applications"
|
||||||
|
$this._API = "deviceAppManagement/mobileApps"
|
||||||
|
$this._QueryList = "?`$filter=(microsoft.graph.managedApp/appAvailability eq null or microsoft.graph.managedApp/appAvailability eq 'lineOfBusiness' or isAssigned eq true)&`$orderby=displayName"
|
||||||
|
$this._QuerySearch = $true
|
||||||
|
$this._Expand = "categories,assignments" # ODataMetadata is set to minimal so assignments can't be autodetected
|
||||||
|
$this._ODataMetadata = "minimal" # categories property not supported with ODataMetadata full
|
||||||
|
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||||
|
$this._PropertiesToRemove = @('uploadState','publishingState','isAssigned','dependentAppCount','supersedingAppCount','supersededAppCount','committedContentVersion','isFeatured','size','categories') #,'minimumSupportedWindowsRelease'
|
||||||
|
$this._AssignmentsType = "mobileAppAssignments"
|
||||||
|
$this._AssignmentPropertiesToKeep = @("@odata.type","target","settings","intent")
|
||||||
|
$this._AssignmentTargetPropertiesToKeep = @("@odata.type","groupId","deviceAndAppManagementAssignmentFilterId","deviceAndAppManagementAssignmentFilterType")
|
||||||
|
$this._ScopeTagsReturnedInList = $false
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._ImportOrder = 60
|
||||||
|
$this._ObjectClass = "ApplicationObject"
|
||||||
|
$this._SubTypeColumn = "ApplicationType=Type"
|
||||||
|
$this._ExtraColumns = @("ApplicationTypeGroup=App type")
|
||||||
|
|
||||||
|
# appUrl: Graph rejects a PATCH that carries it ("The property 'AppUrl'
|
||||||
|
# cannot be patched") - a web app's URL is fixed at creation, as in the
|
||||||
|
# portal. Only webApp has the property, so stripping it is safe for all.
|
||||||
|
$this._PropertiesToRemoveForUpdate = @('platform', 'appUrl')
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.JsonObject.'@OData.Type' -in @('#microsoft.graph.microsoftStoreForBusinessApp','#microsoft.graph.androidStoreApp'))
|
||||||
|
{
|
||||||
|
Write-Log "App type '$($PolicyObject.JsonObject.'@OData.Type')' not supported for import" 2
|
||||||
|
return @{ "Import" = $false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if($PolicyObject.JsonObject.'@OData.Type' -eq '#microsoft.graph.officeSuiteApp')
|
||||||
|
{
|
||||||
|
if($PolicyObject.JsonObject.officeSuiteAppDefaultFileFormat -eq "notConfigured")
|
||||||
|
{
|
||||||
|
$PolicyObject.JsonObject.officeSuiteAppDefaultFileFormat = "officeOpenXMLFormat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
$tmpFilName = $null
|
||||||
|
|
||||||
|
if($SourceObject.IsFromFile) {
|
||||||
|
if(-not ($PolicyObject.JsonObject.PSObject.Properties | Where-Object Name -eq '@odata.type'))
|
||||||
|
{
|
||||||
|
# Add @odata.type property if it is missing. Required by app package import
|
||||||
|
$PolicyObject.JsonObject | Add-Member -MemberType NoteProperty -Name '@odata.type' -Value $SourceObject.JsonObject.'@odata.type'
|
||||||
|
}
|
||||||
|
|
||||||
|
$fi = $SourceObject.FileInfo
|
||||||
|
$tmpFilName = [IO.Path]::Combine($fi.DirectoryName, [string]$SourceObject.JsonObject.FileName)
|
||||||
|
|
||||||
|
if([IO.File]::Exists($tmpFilName) -eq $false)
|
||||||
|
{
|
||||||
|
Write-LogDebug "App content file not found in Json folder: '$tmpFilName'"
|
||||||
|
$tmpFilName = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-ApplicationImportFile $PolicyObject $tmpFilName
|
||||||
|
Start-ApplicationAddInstallScripts $PolicyObject $SourceObject
|
||||||
|
}
|
||||||
|
|
||||||
|
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||||
|
{
|
||||||
|
$fi = [IO.FileInfo]"$PathToFile"
|
||||||
|
|
||||||
|
if((Get-CacheObject "ExportScripts") -eq $true) {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach($rule in ($PolicyObject.JsonObject.detectionRules | Where-Object '@OData.Type' -eq "#microsoft.graph.win32LobAppPowerShellScriptDetection"))
|
||||||
|
{
|
||||||
|
if($rule.ScriptContent)
|
||||||
|
{
|
||||||
|
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_DetectionScript.ps1")), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach($rule in $PolicyObject.JsonObject.requirementRules)
|
||||||
|
{
|
||||||
|
if($rule.'@OData.Type' -eq "#microsoft.graph.win32LobAppPowerShellScriptRequirement")
|
||||||
|
{
|
||||||
|
if($rule.ScriptContent)
|
||||||
|
{
|
||||||
|
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_RequirementScript.ps1")), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.displayName)
|
||||||
|
{
|
||||||
|
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_$($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.displayName)")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.content)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.displayName)
|
||||||
|
{
|
||||||
|
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_$($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.displayName)")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Write-LogError "Failed to export application scripts" $_.Exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Save-SettingStoreValue "Intune" "ExportAppFile" (Get-CacheObject "ExportAppContent")
|
||||||
|
if((Get-CacheObject "ExportAppContent") -eq $true) {
|
||||||
|
if($script:_skipDirectGet -eq $true) {
|
||||||
|
Write-Log "Bulk export: app content download is skipped because direct Graph GET calls are disabled" 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$encryptionSource = Get-SettingValue "IntuneAppDownloadFolder" (Get-SettingValue "IntuneAppPackagesFolder")
|
||||||
|
$pkgPath = $fi.DirectoryName
|
||||||
|
|
||||||
|
if($pkgPath)
|
||||||
|
{
|
||||||
|
Write-Log "Download file $($PolicyObject.JsonObject.FileName)"
|
||||||
|
|
||||||
|
$exportFile = [IO.Path]::Combine($pkgPath, "$($PolicyObject.JsonObject.FileName).encrypted")
|
||||||
|
$contentFileObj = Start-DownloadAppContent $PolicyObject $exportFile -GetContentFileInfoOnly
|
||||||
|
$encryptionFile = Find-AppEncryptionFile $PolicyObject $contentFileObj $encryptionSource
|
||||||
|
if($encryptionFile -and [IO.File]::Exists($encryptionFile))
|
||||||
|
{
|
||||||
|
Start-DownloadFile $contentFileObj.azureStorageUri $exportFile
|
||||||
|
|
||||||
|
if([IO.File]::Exists($exportFile))
|
||||||
|
{
|
||||||
|
Write-Log "Decrypt file"
|
||||||
|
$encryptionInfo = ConvertFrom-Json ([IO.File]::ReadAllText($encryptionFile))
|
||||||
|
if($encryptionInfo.fileEncryptionInfo)
|
||||||
|
{
|
||||||
|
$encryptionInfo = $encryptionInfo.fileEncryptionInfo
|
||||||
|
}
|
||||||
|
$destination = $pkgPath + ("\$($PolicyObject.JsonObject.FileName)" -replace 'intunewin$', 'zip')
|
||||||
|
Start-DecryptFile $exportFile $destination $encryptionInfo.encryptionKey $encryptionInfo.initializationVector
|
||||||
|
}
|
||||||
|
|
||||||
|
try { [IO.File]::Delete($exportFile) }
|
||||||
|
catch {
|
||||||
|
Write-LogError "Failed to delete exported encrypted file" $_.Exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Log "Could not find encryption file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.JsonObject.'@odata.type' -eq "#microsoft.graph.windowsMicrosoftEdgeApp")
|
||||||
|
{
|
||||||
|
$assignments = $SourceObject.JsonObject.assignments
|
||||||
|
foreach($assignment in $assignments)
|
||||||
|
{
|
||||||
|
Remove-Property $assignment.target "deviceAndAppManagementAssignmentFilterId"
|
||||||
|
Remove-Property $assignment.target "deviceAndAppManagementAssignmentFilterType"
|
||||||
|
}
|
||||||
|
return (@{"Assignments" = $assignments})
|
||||||
|
}
|
||||||
|
elseif($PolicyObject.JsonObject.'@odata.type' -eq "#microsoft.graph.winGetApp")
|
||||||
|
{
|
||||||
|
Write-LogDebug "Wait for '$($PolicyObject.Name)' to be published"
|
||||||
|
$i = 2
|
||||||
|
Start-Sleep -s ($i)
|
||||||
|
$x = 0
|
||||||
|
while($x -lt 10)
|
||||||
|
{
|
||||||
|
$appInfo = Invoke-MSGraphAPI -Url "$($PolicyObject.PolicyType.API)/$($PolicyObject.id)" -ODataMetadata "skip" -TokenId $PolicyObject.TokenId
|
||||||
|
if($appInfo.publishingState -eq "Published")
|
||||||
|
{
|
||||||
|
Write-LogDebug "Application '$($PolicyObject.Name)' is published"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
Start-Sleep -s ($i)
|
||||||
|
$x++
|
||||||
|
if($x -ge 5) { $i++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log "Application '$($PolicyObject.Name)' is not published. Skipping assignments" 2
|
||||||
|
return (@{"Import" = $false})
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
PostBulkImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
Add-ApplicationReferences $PolicyObject $SourceObject
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.windowsMobileMSI")
|
||||||
|
{
|
||||||
|
Remove-Property $PolicyObject.JsonObject "useDeviceContext"
|
||||||
|
}
|
||||||
|
elseif($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.officeSuiteApp")
|
||||||
|
{
|
||||||
|
Remove-Property $PolicyObject.JsonObject "officeConfigurationXml"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "officePlatformArchitecture"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "developer"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "owner"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "publisher"
|
||||||
|
}
|
||||||
|
elseif($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.winGetApp")
|
||||||
|
{
|
||||||
|
# Immutable after creation - Graph: "The property
|
||||||
|
# 'InstallExperience' cannot be patched."
|
||||||
|
Remove-Property $PolicyObject.JsonObject "installExperience"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "packageIdentifier"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "manifestHash"
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Property $PolicyObject.JsonObject "appStoreUrl"
|
||||||
|
|
||||||
|
# assignments is a navigation property (managed via /assign), not
|
||||||
|
# inline-PATCHable: "Cannot apply PATCH to navigation property
|
||||||
|
# 'assignments' on entity type mobileApp".
|
||||||
|
Remove-Property $PolicyObject.JsonObject "assignments"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
# Exported app json carries a top-level @odata.id (and, when assignments
|
||||||
|
# were expanded, assignments@odata.context) - both identify the
|
||||||
|
# mobileApps entity set, which only hosts apps.
|
||||||
|
if($PolicyObject.'@odata.id' -like '*deviceAppManagement/mobileApps(*') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if($PolicyObject.'assignments@odata.context' -like '*#deviceAppManagement/mobileApps(*') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class ApplicationObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
Hidden [String]$_AppTypeName = $null
|
||||||
|
Hidden [String]$_AppTypeGroup = $null
|
||||||
|
Hidden [String]$_InstallerType = $null
|
||||||
|
# Carries phase-1 → phase-2 routing state (script id → { Script; Target })
|
||||||
|
# so the orchestrator's phase-2 ApplyResult can find which install/uninstall
|
||||||
|
# target object to attach #ScriptInfo to without re-walking the script list.
|
||||||
|
Hidden [Hashtable]$_SubResourceState = $null
|
||||||
|
|
||||||
|
ApplicationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
ApplicationObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "ApplicationType")
|
||||||
|
$this._PlatformName = Get-GraphApplicationPlatform $this
|
||||||
|
$this._AppTypeGroup = Get-GraphApplicationTypeGroup $this
|
||||||
|
$this._AppTypeName = (Get-GraphApplicationName $this)
|
||||||
|
$this._HasSubResourceBatch = $true
|
||||||
|
|
||||||
|
if($this.JsonObject."@OData.Type" -eq "#microsoft.graph.winGetApp") {
|
||||||
|
if($this.JsonObject.packageIdentifier -like "9*")
|
||||||
|
{
|
||||||
|
$this._InstallerType = "UWP"
|
||||||
|
}
|
||||||
|
elseif($this.JsonObject.packageIdentifier -like "X*")
|
||||||
|
{
|
||||||
|
$this._InstallerType = "Win32"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Log "Unknown package identifier for app $($this.Name): $($this.JsonObject.packageIdentifier)" 2
|
||||||
|
$this._InstallerType = "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-ObjectProperty $this "ApplicationType" { $this._AppTypeName }
|
||||||
|
Add-ObjectProperty $this "ApplicationTypeGroup" { $this._AppTypeGroup }
|
||||||
|
Add-ObjectProperty $this "InstallerType" { $this._InstallerType }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# Phase 1: relationships (when there are dependencies/supersedences) and the
|
||||||
|
# win32 script list (when an active install/uninstall script is referenced).
|
||||||
|
# Phase 2 follow-ups are produced by the phase-1 ApplyResult below.
|
||||||
|
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||||
|
{
|
||||||
|
if($Phase -ne 1) { return @() }
|
||||||
|
|
||||||
|
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
|
||||||
|
if(([int64]($this.Object.dependentAppCount) -gt 0) -or ([int64]($this.Object.supersededAppCount) -gt 0)) {
|
||||||
|
[void]$reqs.Add([PSCustomObject]@{
|
||||||
|
Key = 'rel'
|
||||||
|
Url = "deviceAppManagement/mobileApps/$($this.Id)/relationships?`$filter=targetType%20eq%20microsoft.graph.mobileAppRelationshipType%27child%27"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if($this.Object.'@odata.type' -eq '#microsoft.graph.win32LobApp' -and
|
||||||
|
($this.Object.activeInstallScript.targetId -or $this.Object.activeUninstallScript.targetId)) {
|
||||||
|
[void]$reqs.Add([PSCustomObject]@{
|
||||||
|
Key = 'scriptlist'
|
||||||
|
Url = "deviceAppManagement/mobileApps/$($this.Id)/microsoft.graph.win32LobApp/contentVersions/$($this.Object.committedContentVersion)/scripts/"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reqs.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||||
|
{
|
||||||
|
if($Phase -eq 1 -and $Key -eq 'rel') {
|
||||||
|
$deps = @()
|
||||||
|
$sups = @()
|
||||||
|
foreach($rel in @($Body.value)) {
|
||||||
|
if($rel.'@odata.type' -eq '#microsoft.graph.mobileAppDependency') {
|
||||||
|
$deps += "$($rel.targetDisplayName)|!|$($rel.targetDisplayVersion)|!|$($rel.targetId)|!|$($rel.dependencyType)"
|
||||||
|
}
|
||||||
|
elseif($rel.'@odata.type' -eq '#microsoft.graph.mobileAppSupersedence') {
|
||||||
|
$sups += "$($rel.targetDisplayName)|!|$($rel.targetDisplayVersion)|!|$($rel.targetId)|!|$($rel.supersedenceType)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($deps.Count -gt 0) {
|
||||||
|
$this.Object | Add-Member -MemberType NoteProperty -Name '#CustomRefDependency' -Value ($deps -join '|*|') -Force
|
||||||
|
}
|
||||||
|
if($sups.Count -gt 0) {
|
||||||
|
$this.Object | Add-Member -MemberType NoteProperty -Name '#CustomRefSupersedence' -Value ($sups -join '|*|') -Force
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
if($Phase -eq 1 -and $Key -eq 'scriptlist') {
|
||||||
|
if($null -eq $this._SubResourceState) { $this._SubResourceState = @{} }
|
||||||
|
$followups = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
foreach($script in @($Body.value)) {
|
||||||
|
$target = $null
|
||||||
|
if($this.Object.activeInstallScript.targetId -eq $script.id) {
|
||||||
|
$target = $this.Object.activeInstallScript
|
||||||
|
}
|
||||||
|
elseif($this.Object.activeUninstallScript.targetId -eq $script.id) {
|
||||||
|
$target = $this.Object.activeUninstallScript
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log "Script with id $($script.id) is not referenced by active install or uninstall script. Skipping." 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$stateKey = "scriptcontent_$($script.id)"
|
||||||
|
$this._SubResourceState[$stateKey] = [PSCustomObject]@{ Script = $script; Target = $target }
|
||||||
|
[void]$followups.Add([PSCustomObject]@{
|
||||||
|
Key = $stateKey
|
||||||
|
Url = "deviceAppManagement/mobileApps/$($this.Id)/microsoft.graph.win32LobApp/contentVersions/$($this.Object.committedContentVersion)/scripts/$($script.id)?`$select=Id,Content"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return $followups.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
if($Phase -eq 2 -and $Key -like 'scriptcontent_*' -and $null -ne $this._SubResourceState) {
|
||||||
|
$state = $this._SubResourceState[$Key]
|
||||||
|
if($state) {
|
||||||
|
if($Body -and $Body.Content) {
|
||||||
|
$state.Script | Add-Member -MemberType NoteProperty -Name 'content' -Value $Body.Content -Force
|
||||||
|
}
|
||||||
|
$state.Target | Add-Member -MemberType NoteProperty -Name '#ScriptInfo' -Value $state.Script -Force
|
||||||
|
$this._SubResourceState.Remove($Key) | Out-Null
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
function Start-ApplicationImportFile
|
||||||
|
{
|
||||||
|
param($PolicyObject, $PackageFile = $null)
|
||||||
|
|
||||||
|
if(-not $PolicyObject.JsonObject.'@odata.type') { return }
|
||||||
|
|
||||||
|
if($null -eq $PackageFile)
|
||||||
|
{
|
||||||
|
$pkgPath = Get-SettingValue "IntuneAppPackagesFolder"
|
||||||
|
|
||||||
|
if(-not $pkgPath -or [IO.Directory]::Exists($pkgPath) -eq $false)
|
||||||
|
{
|
||||||
|
Write-LogDebug "Package source directory in Settings is not specified" 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elseif([IO.Directory]::Exists($pkgPath) -eq $false)
|
||||||
|
{
|
||||||
|
Write-LogDebug "Package source directory '$($pkgPath)' does not exist" 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$PackageFile = [IO.Path]::Combine($pkgPath, "$($PolicyObject.JsonObject.fileName)")
|
||||||
|
$packageFile2 = [IO.Path]::Combine($pkgPath, $PolicyObject.Name, "$($PolicyObject.JsonObject.fileName)")
|
||||||
|
if([IO.File]::Exists($PackageFile) -eq $false -and [IO.File]::Exists($packageFile2)) {
|
||||||
|
$PackageFile = $packageFile2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fi = [IO.FileInfo]$PackageFile
|
||||||
|
|
||||||
|
if($fi.Exists -eq $false)
|
||||||
|
{
|
||||||
|
Write-LogDebug "Package source file $($fi.FullName) not found" 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Status "Import application package file $($fi.FullName)"
|
||||||
|
Write-Log "Import application file '$($($fi.FullName))' for $($PolicyObject.Name)"
|
||||||
|
|
||||||
|
$appType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
|
||||||
|
|
||||||
|
if($appType -eq "microsoft.graph.win32LobApp")
|
||||||
|
{
|
||||||
|
$fileEncryptionInfo = Copy-Win32LOBPackage $PackageFile $PolicyObject
|
||||||
|
}
|
||||||
|
elseif($appType -eq "microsoft.graph.windowsMobileMSI")
|
||||||
|
{
|
||||||
|
$fileEncryptionInfo = Copy-MSILOB $PackageFile $PolicyObject
|
||||||
|
}
|
||||||
|
elseif($appType -eq "microsoft.graph.windowsUniversalAppX")
|
||||||
|
{
|
||||||
|
$fileEncryptionInfo = Copy-MSIXLOB $PackageFile $PolicyObject
|
||||||
|
}
|
||||||
|
elseif($appType -eq "microsoft.graph.iosLOBApp")
|
||||||
|
{
|
||||||
|
$fileEncryptionInfo = Copy-iOSLOB $PackageFile $PolicyObject
|
||||||
|
}
|
||||||
|
elseif($appType -eq "microsoft.graph.androidLOBApp")
|
||||||
|
{
|
||||||
|
$fileEncryptionInfo = Copy-AndroidLOB $PackageFile $PolicyObject
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Log "Unsupported application type $appType. File will not be uploaded" 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if((Get-SettingValue "IntuneSaveEncryptionFile") -eq $true)
|
||||||
|
{
|
||||||
|
if($fileEncryptionInfo)
|
||||||
|
{
|
||||||
|
$jsonEncryptionInfo = $fileEncryptionInfo | ConvertTo-Json -Depth 10
|
||||||
|
|
||||||
|
$pkgPath = Get-SettingValue "IntuneAppDownloadFolder" (Get-SettingValue "IntuneAppPackagesFolder")
|
||||||
|
if($pkgPath -and [IO.Directory]::Exists($pkgPath))
|
||||||
|
{
|
||||||
|
$obj = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)" -ODataMetadata "Minimal" -TokenId $PolicyObject._TokenID
|
||||||
|
$fullPath = [IO.Path]::Combine($pkgPath, "$($obj.displayName)_$($obj.id)_$($obj.committedContentVersion).json")
|
||||||
|
$jsonEncryptionInfo | Out-File -FilePath $fullPath -Force -Encoding utf8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-ApplicationAddInstallScripts
|
||||||
|
{
|
||||||
|
param($PolicyObject, $FromAppObj)
|
||||||
|
|
||||||
|
if($FromAppObj -and ($FromAppObj.activeInstallScript."#ScriptInfo" -or $FromAppObj.activeUninstallScript."#ScriptInfo"))
|
||||||
|
{
|
||||||
|
Write-Log "Importing scripts for $($PolicyObject.displayName)"
|
||||||
|
|
||||||
|
$scriptsAdded = $false
|
||||||
|
$jsonData = @{}
|
||||||
|
$jsonData."@odata.type" = "#microsoft.graph.win32LobApp"
|
||||||
|
$jsonData."committedContentVersion" = "1"
|
||||||
|
|
||||||
|
foreach ($scriptType in @('activeInstallScript','activeUninstallScript')) {
|
||||||
|
$scriptInfo = $FromAppObj.$scriptType.'#ScriptInfo'
|
||||||
|
if (-not $scriptInfo) { continue }
|
||||||
|
|
||||||
|
Write-Log "Add $($scriptType -replace '^active','') script: $($scriptInfo.displayName)"
|
||||||
|
|
||||||
|
$json = [ordered]@{
|
||||||
|
'@odata.type' = $scriptInfo.'@odata.type'
|
||||||
|
displayName = $scriptInfo.displayName
|
||||||
|
enforceSignatureCheck = $scriptInfo.enforceSignatureCheck
|
||||||
|
runAs32Bit = $scriptInfo.runAs32Bit
|
||||||
|
content = $scriptInfo.content
|
||||||
|
} | ConvertTo-Json -Depth 10 -Compress
|
||||||
|
|
||||||
|
$scriptObject = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -Method POST -Content $json -TokenId $PolicyObject._TokenID
|
||||||
|
|
||||||
|
if ($scriptObject) {
|
||||||
|
$jsonData.$scriptType = @{ targetId = $scriptObject.Id }
|
||||||
|
$scriptsAdded = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$i = 0
|
||||||
|
while($true)
|
||||||
|
{
|
||||||
|
$scripts = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -TokenId $PolicyObject._TokenID
|
||||||
|
if(-not $scripts)
|
||||||
|
{
|
||||||
|
Write-Log "Failed to retrieve scripts for app after adding. Skipping Install/Uninstall script config." 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if(($scripts.value.state | Select -Unique) -eq "commitSuccess")
|
||||||
|
{
|
||||||
|
Write-Log "Scripts added successfully"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if($i -ge 12)
|
||||||
|
{
|
||||||
|
Write-Log "Install/Uninstall scripts are still not in pending state after waiting for 1 minute." 3
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log "Waiting for scripts to be added..."
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
$i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if($scriptsAdded)
|
||||||
|
{
|
||||||
|
Write-Log "Add script info to app"
|
||||||
|
$json = ConvertTo-Json $jsonData -Depth 10
|
||||||
|
$status = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)" -Method PATCH -Body $json -TokenId $PolicyObject._TokenID -FullResponseObject
|
||||||
|
if($status.Success)
|
||||||
|
{
|
||||||
|
Write-Log "Install/Uninstall script info updated successfully"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Log "Failed to update Install/Uninstall script info" 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-ApplicationReferences
|
||||||
|
{
|
||||||
|
param($PolicyObject, $SourceObject)
|
||||||
|
|
||||||
|
if($SourceObject.JsonObject."#CustomRefDependency" -or $SourceObject.JsonObject."#CustomRefSupersedence")
|
||||||
|
{
|
||||||
|
Write-Log "Adding app references for $($PolicyObject.displayName)"
|
||||||
|
|
||||||
|
$depAppsInfo = $SourceObject.JsonObject."#CustomRefDependency"
|
||||||
|
$supAppsInfo = $SourceObject.JsonObject."#CustomRefSupersedence"
|
||||||
|
|
||||||
|
$releationShips = [PSCustomObject]@{
|
||||||
|
relationships = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
if($depAppsInfo)
|
||||||
|
{
|
||||||
|
foreach($depApp in ($depAppsInfo -split "[|][*][|]"))
|
||||||
|
{
|
||||||
|
$appName, $appVer, $appId, $appType = $depApp -split "[|][!][|]"
|
||||||
|
if(-not $appName -or -not $appVer)
|
||||||
|
{
|
||||||
|
Write-Log "Could not get Name and Version from string: $($PolicyObject.displayName)" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tmpApps = (Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $PolicyObject.TokenId).value
|
||||||
|
if(-not $tmpApps)
|
||||||
|
{
|
||||||
|
Write-Log "No application found with name $appName" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tmpApp = $tmpApps | Where-Object displayVersion -eq $appVer
|
||||||
|
if(-not $tmpApp)
|
||||||
|
{
|
||||||
|
Write-Log "No $appName application found with version $appVer" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
elseif(($tmpApp | Measure-Object).Count -gt 1)
|
||||||
|
{
|
||||||
|
Write-Log "Multiple $appName application found with version $appVer" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Write-Log "Add $appName ($appVer) to Dependency list"
|
||||||
|
$releationShips.relationships += [PSCustomObject]@{
|
||||||
|
"@odata.type" = "#microsoft.graph.mobileAppDependency"
|
||||||
|
targetId = $tmpApp.Id
|
||||||
|
dependencyType = $appType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($supAppsInfo)
|
||||||
|
{
|
||||||
|
foreach($suppApp in ($supAppsInfo -split "[|][*][|]"))
|
||||||
|
{
|
||||||
|
$appName, $appVer, $appId, $appType = $suppApp -split "[|][!][|]"
|
||||||
|
if(-not $appName -or -not $appVer)
|
||||||
|
{
|
||||||
|
Write-Log "Could not get Name and Version from string: $($PolicyObject.displayName)" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tmpApps = (Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $PolicyObject.TokenId).value
|
||||||
|
if(-not $tmpApps)
|
||||||
|
{
|
||||||
|
Write-Log "No application found with name $appName" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tmpApp = $tmpApps | Where-Object displayVersion -eq $appVer
|
||||||
|
if(-not $tmpApp)
|
||||||
|
{
|
||||||
|
Write-Log "No $appName application found with version $appVer" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
elseif(($tmpApp | Measure-Object).Count -gt 1)
|
||||||
|
{
|
||||||
|
Write-Log "Multiple $appName application found with version $appVer" 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Write-Log "Add $appName ($appVer) to Supersedence list"
|
||||||
|
$releationShips.relationships += [PSCustomObject]@{
|
||||||
|
"@odata.type" = "#microsoft.graph.mobileAppSupersedence"
|
||||||
|
targetId = $tmpApp.Id
|
||||||
|
supersedenceType = $appType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($releationShips.relationships.Count -gt 0)
|
||||||
|
{
|
||||||
|
$json = Update-JsonForEnvironment (ConvertTo-Json $releationShips -Depth 20) $PolicyObject $PolicyObject.TokenId
|
||||||
|
|
||||||
|
Write-Log "Update app references"
|
||||||
|
Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.Id)/updateRelationships" -Method "POST" -Body $json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# iOS LOB App Provisioning Configurations
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Apple-issued provisioning profiles (.mobileprovision files) that travel
|
||||||
|
# alongside iOS LOB apps. Without these, signed LOB apps stop launching when
|
||||||
|
# the embedded profile expires. Endpoint at
|
||||||
|
# /deviceAppManagement/iosLobAppProvisioningConfigurations.
|
||||||
|
#
|
||||||
|
# `payload` (Edm.Binary) carries the base64-encoded .mobileprovision file;
|
||||||
|
# it round-trips through JSON export/import as the payload string.
|
||||||
|
|
||||||
|
# region IosLobAppProvisioningConfigurationsType
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class IosLobAppProvisioningConfigurationsType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
IosLobAppProvisioningConfigurationsType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ApplicationsGroup")
|
||||||
|
$this._PolicyName = "iOS app provisioning profiles"
|
||||||
|
$this._ID = "IosLobAppProvisioningConfigurations"
|
||||||
|
# Platform default: the endpoint serves exactly one platform and the
|
||||||
|
# objects carry no platforms/platformType field, so the column would
|
||||||
|
# otherwise be blank (iosLobAppProvisioningConfigurations is iOS-only).
|
||||||
|
$this._PlatformName = Get-LanguageString "Platform.iOS" -IgnoreMissing
|
||||||
|
$this._API = "deviceAppManagement/iosLobAppProvisioningConfigurations"
|
||||||
|
# No dedicated icon yet — fall back to Applications. Tracked in TODO
|
||||||
|
# under the icons-for-new-APIs entry.
|
||||||
|
$this._Icon = "Applications"
|
||||||
|
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
|
||||||
|
# version + expirationDateTime are derived from the embedded
|
||||||
|
# .mobileprovision; createdDateTime / lastModifiedDateTime are
|
||||||
|
# server-set. Strip on POST/PATCH.
|
||||||
|
$this._PropertiesToRemove = @('version','expirationDateTime')
|
||||||
|
$this._PropertiesToRemoveForUpdate = @('version','expirationDateTime','payload','payloadFileName')
|
||||||
|
# Default `assignments` shape with simple {target} — no overrides
|
||||||
|
# needed beyond the inherited defaults.
|
||||||
|
$this._ImportOrder = 90
|
||||||
|
$this._ObjectClass = "IosLobAppProvisioningConfigurationObject"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class IosLobAppProvisioningConfigurationObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
IosLobAppProvisioningConfigurationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
IosLobAppProvisioningConfigurationObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "IosLobAppProvisioningConfigurationsType")
|
||||||
|
$this._PlatformName = Get-LanguageString "Platform.iOS" -IgnoreMissing
|
||||||
|
if(-not $this._PlatformName) { $this._PlatformName = "iOS/iPadOS" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#ImportOrder 32
|
||||||
|
|
||||||
|
class IntuneAssignmentsProviderBase
|
||||||
|
{
|
||||||
|
[string] $Name
|
||||||
|
[string] $Value
|
||||||
|
[string] $OptionsXaml
|
||||||
|
|
||||||
|
IntuneAssignmentsProviderBase([string]$name, [string]$value, [string]$optionsXaml)
|
||||||
|
{
|
||||||
|
$this.Name = $name
|
||||||
|
$this.Value = $value
|
||||||
|
$this.OptionsXaml = $optionsXaml
|
||||||
|
}
|
||||||
|
|
||||||
|
[bool] Validate() { return $true }
|
||||||
|
[void] SaveSettings() { }
|
||||||
|
[object[]] GetAssignments() { return @() }
|
||||||
|
[string] ToString() { return $this.Name }
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntuneAssignmentsFolderProvider : IntuneAssignmentsProviderBase
|
||||||
|
{
|
||||||
|
[string] $ExportPath
|
||||||
|
|
||||||
|
IntuneAssignmentsFolderProvider() : base(
|
||||||
|
"From Folder",
|
||||||
|
"folder",
|
||||||
|
"IntuneToolsAssignmentsFolderOptions"
|
||||||
|
) {}
|
||||||
|
|
||||||
|
[bool] Validate()
|
||||||
|
{
|
||||||
|
if([string]::IsNullOrWhiteSpace($this.ExportPath) -or -not [IO.Directory]::Exists($this.ExportPath))
|
||||||
|
{
|
||||||
|
throw "Select a valid folder containing exported objects"
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[void] SaveSettings()
|
||||||
|
{
|
||||||
|
Save-SettingStoreValue "IntuneAssignments" "ExportPath" $this.ExportPath
|
||||||
|
}
|
||||||
|
|
||||||
|
[object[]] GetAssignments()
|
||||||
|
{
|
||||||
|
return (Get-IntuneAssignmentsFromFolder $this.ExportPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntuneAssignmentsIntuneProvider : IntuneAssignmentsProviderBase
|
||||||
|
{
|
||||||
|
IntuneAssignmentsIntuneProvider() : base(
|
||||||
|
"From Intune",
|
||||||
|
"intune",
|
||||||
|
"IntuneToolsAssignmentsIntuneOptions"
|
||||||
|
) {}
|
||||||
|
|
||||||
|
[bool] Validate()
|
||||||
|
{
|
||||||
|
# Ask the active auth provider whether a session exists, not the MSAL-specific
|
||||||
|
# $script:MSALTokens registry. The latter is empty when MgGraph is the active
|
||||||
|
# provider, so this method incorrectly blocked signed-in MgGraph users.
|
||||||
|
$signedIn = $false
|
||||||
|
try {
|
||||||
|
$provider = Get-AuthProvider
|
||||||
|
if($provider) {
|
||||||
|
$userInfo = $provider.GetUserInfo(0)
|
||||||
|
if($userInfo) { $signedIn = $true }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
if(-not $signedIn)
|
||||||
|
{
|
||||||
|
throw "You must be logged in to read assignments from Intune"
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[object[]] GetAssignments()
|
||||||
|
{
|
||||||
|
return (Get-IntuneAssignmentsFromIntune)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class ComplianceGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
ComplianceGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "Compliance"
|
||||||
|
$this._Name = "Compliance"
|
||||||
|
$this._Icon = "CompliancePolicies"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Device Compliance
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Device Compliance
|
||||||
|
class DeviceComplianceType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
DeviceComplianceType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||||
|
$this._PolicyName = "Compliance Policy"
|
||||||
|
$this._ID = "CompliancePolicies"
|
||||||
|
$this._API = "deviceManagement/deviceCompliancePolicies"
|
||||||
|
# This endpoint answers HTTP 400 to startswith() on displayName
|
||||||
|
# (verified 2026-08-27; 'displayName eq' works, prefix search does not),
|
||||||
|
# so name searches filter client-side instead.
|
||||||
|
$this._SupportsNameFilter = $false
|
||||||
|
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||||
|
$this._Expand = "scheduledActionsForRule(`$expand=scheduledActionConfigurations)"
|
||||||
|
# "Locations" (v3's deprecated Intune managementConditions type) is not a
|
||||||
|
# PolicyType here, so listing it resolved to nothing on import.
|
||||||
|
$this._Dependencies = @("Notifications","ComplianceScripts")
|
||||||
|
$this._ObjectClass = "DeviceComplianceObject"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||||
|
{
|
||||||
|
foreach($scheduledActionsForRule in $PolicyObject.JsonObject.scheduledActionsForRule)
|
||||||
|
{
|
||||||
|
foreach($scheduledActionConfiguration in $scheduledActionsForRule.scheduledActionConfigurations)
|
||||||
|
{
|
||||||
|
foreach($notificationMessageCCGroup in $scheduledActionConfiguration.notificationMessageCCList)
|
||||||
|
{
|
||||||
|
Add-GraphMigrationObject $notificationMessageCCGroup "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
$api = "deviceManagement/deviceCompliancePolicies/$($PolicyObject.Id)/scheduleActionsForRules"
|
||||||
|
|
||||||
|
$tmpObj = [PSCustomObject]@{
|
||||||
|
deviceComplianceScheduledActionForRules = $PolicyObject.JsonObject.scheduledActionsForRule
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = ConvertTo-Json $tmpObj -Depth 20
|
||||||
|
Invoke-MSGraphAPI -Url $api -Content $json -HttpMethod "POST" -TokenId $PolicyObject._TokenId | Out-Null
|
||||||
|
|
||||||
|
Remove-Property $PolicyObject.JsonObject "scheduledActionsForRule"
|
||||||
|
|
||||||
|
return (@{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class DeviceComplianceObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
DeviceComplianceObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
DeviceComplianceObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "DeviceComplianceType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Device Compliance V2
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
Class DeviceComplianceV2Type : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
DeviceComplianceV2Type() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||||
|
# Its own name so a mixed Compliance list tells V1 from V2 without a
|
||||||
|
# separate "Policy base" column.
|
||||||
|
$this._PolicyName = "Compliance Policy (Settings Catalog)"
|
||||||
|
$this._PolicyBaseName = "Compliance Policy V2"
|
||||||
|
$this._APITitle = "Compliance Policy (Linux)"
|
||||||
|
$this._ID = "CompliancePoliciesV2"
|
||||||
|
$this._API = "deviceManagement/compliancePolicies"
|
||||||
|
$this._PropertiesToRemove = @('settingCount')
|
||||||
|
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||||
|
$this._NameProperty = "Name"
|
||||||
|
$this._Expand = "settings"
|
||||||
|
$this._ObjectClass = "DeviceComplianceV2Object"
|
||||||
|
$this._Icon = "CompliancePolicies"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
# V2 compliance runs on the settings-catalog engine: updates must PUT
|
||||||
|
# the full body (including settings); PATCH with settings is rejected.
|
||||||
|
return @{ "Method" = "PUT" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class DeviceComplianceV2Object : IntunePolicyBase
|
||||||
|
{
|
||||||
|
DeviceComplianceV2Object([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
DeviceComplianceV2Object() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "DeviceComplianceV2Type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Compliance Scripts
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
Class ComplianceScriptsType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
ComplianceScriptsType() : Base()
|
||||||
|
{
|
||||||
|
([ComplianceScriptsType]$this).Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||||
|
$this._APITitle = "Compliance Scripts"
|
||||||
|
$this._PolicyName = "Compliance Script"
|
||||||
|
$this._ID = "ComplianceScripts"
|
||||||
|
$this._API = "deviceManagement/deviceComplianceScripts"
|
||||||
|
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||||
|
$this._ObjectClass = "ComplianceScriptObject"
|
||||||
|
$this._Icon = "Scripts"
|
||||||
|
# Graph rejects PATCHing the read-only version back ("Invalid property
|
||||||
|
# name: Version").
|
||||||
|
$this._PropertiesToRemoveForUpdate = @('version')
|
||||||
|
# Custom compliance scripts are REFERENCED by compliance policies, not
|
||||||
|
# assigned to devices - the portal has no Assignments blade and Graph's
|
||||||
|
# /assign action rejects the request (400 "Action parameters do not
|
||||||
|
# contain parameter 'deviceHealthScriptAssignments'").
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class ComplianceScriptObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
ComplianceScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
ComplianceScriptObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PlatformName = Get-LanguageString "Platform.windows10AndLater"
|
||||||
|
$this._PolicyType = (Get-SingletonObject "ComplianceScriptsType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Compliance Scripts - Linux
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
Class ComplianceScriptsLinuxType : ReusableSettingsTypeBase
|
||||||
|
{
|
||||||
|
ComplianceScriptsLinuxType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||||
|
$this._APITitle = "Compliance Scripts (Linux)"
|
||||||
|
$this._PolicyName = "Compliance Script"
|
||||||
|
$this._ID = "ComplianceScriptsScriptsLinux"
|
||||||
|
$this._QueryList = "?`$filter=settingDefinitionId eq 'linux_customcompliance_discoveryscript_reusablesetting'"
|
||||||
|
# Reusable settings carry no roleScopeTagIds in the Graph schema.
|
||||||
|
$this._ScopeTagProperty = ""
|
||||||
|
$this._ObjectClass = "ComplianceScriptLinuxObject"
|
||||||
|
$this._Icon = "Scripts"
|
||||||
|
$this._Folder = "ReusableSettings"
|
||||||
|
$this._PolicyTypeOrder = 140
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementReusablePolicySetting") { return $false }
|
||||||
|
|
||||||
|
if($PolicyObject.settingDefinitionId -eq 'linux_customcompliance_discoveryscript_reusablesetting') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class ComplianceScriptLinuxObject : ReusableSettingsObjectBase
|
||||||
|
{
|
||||||
|
ComplianceScriptLinuxObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
ComplianceScriptLinuxObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "ComplianceScriptsLinuxType")
|
||||||
|
$this._PlatformName = Get-LanguageString "Platform.linux" -IgnoreMissing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Compliance Notifications
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
Class NotificationsType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
NotificationsType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
|
||||||
|
$this._PolicyName = "Notifications"
|
||||||
|
$this._ID = "Notifications"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._API = "deviceManagement/notificationMessageTemplates"
|
||||||
|
#$this._QueryList = "?`$filter=displayName ne 'EnrollmentNotificationInternalMEO'"
|
||||||
|
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
|
||||||
|
$this._ObjectClass = "NotificationObject"
|
||||||
|
$this._ImportOrder = 40
|
||||||
|
$this._Expand = "localizedNotificationMessages"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
# notificationMessageTemplate has no `assignments` navigation property at
|
||||||
|
# all (its only nav is localizedNotificationMessages), so both the nav GET
|
||||||
|
# and ?$expand=assignments return 400. Notification templates are targeted
|
||||||
|
# from compliance policies, not assigned - don't ask for assignments.
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
# notificationMessageTemplate has no description property in Graph.
|
||||||
|
$this._HasDescription = $false
|
||||||
|
# localizedNotificationMessages is a navigation property - PATCHing it
|
||||||
|
# inline is rejected; messages are managed on their own sub-endpoint.
|
||||||
|
$this._PropertiesToRemoveForUpdate = @('localizedNotificationMessages','defaultLocale')
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
Remove-Property $PolicyObject.JsonObject "defaultLocale"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "localizedNotificationMessages"
|
||||||
|
Remove-Property $PolicyObject.JsonObject "localizedNotificationMessages@odata.context"
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
$this.UpdateNotificationMessages($PolicyObject, $SourceObject.JsonObject.localizedNotificationMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden UpdateNotificationMessages($PolicyObject, $localizedNotificationMessages)
|
||||||
|
{
|
||||||
|
if(-not $localizedNotificationMessages) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $false
|
||||||
|
foreach($localizedNotificationMessage in $localizedNotificationMessages)
|
||||||
|
{
|
||||||
|
Remove-GraphPropertiesForImport $this $localizedNotificationMessage
|
||||||
|
$response = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)/localizedNotificationMessages" -Body ($localizedNotificationMessage | ConvertTo-Json -Depth 20) -Method "POST" -FullResponseObject
|
||||||
|
if($response.Success) {
|
||||||
|
Write-log "Notification message '$($localizedNotificationMessage.subject)' ($($localizedNotificationMessage.locale)) added successfully"
|
||||||
|
$updated = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-log "Failed to add notification message: '$($localizedNotificationMessage.subject)' ($($localizedNotificationMessage.locale))" 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($updated) {
|
||||||
|
[void]$PolicyObject.Get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.'displayName' -eq "EnrollmentNotificationInternalMEO") { return $false } # Skip built in
|
||||||
|
|
||||||
|
return (([IntunePolicyTypeBase]$this).CheckPolicy($PolicyObject))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class NotificationObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
NotificationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
NotificationObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "NotificationsType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
#ImportOrder 220
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Apple Update Group
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class ConditionalAccessGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
ConditionalAccessGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "ConditionalAccess"
|
||||||
|
$this._Name = "Conditional Access"
|
||||||
|
$this._Icon = "ConditionalAccess"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Conditional Access Policies
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Conditional Access Policies
|
||||||
|
class ConditionalAccessType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
ConditionalAccessType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||||
|
$this._PolicyName = "Conditional Access"
|
||||||
|
$this._ID = "ConditionalAccess"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._API = "identity/conditionalAccess/policies"
|
||||||
|
# Entra object - no roleScopeTagIds in the Graph schema (a PATCH
|
||||||
|
# no-ops), so no scope-tag support.
|
||||||
|
$this._ScopeTagProperty = ""
|
||||||
|
$this._Dependencies = @("NamedLocations","Applications","TermsOfUse","AuthenticationStrengths","AssignmentFilters")
|
||||||
|
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||||
|
$this._ObjectClass = "ConditionalAccessObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
$this._HasPageSizeSupport = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
# Tenant-lockout guard: rewrite the imported policy's state per the
|
||||||
|
# ConditionalAccessState setting (default: disabled). Logic lives in
|
||||||
|
# Internal/IntuneManager.ps1 so it is unit-testable.
|
||||||
|
Set-CAPolicyImportState $PolicyObject
|
||||||
|
|
||||||
|
if($PolicyObject.grantControls.authenticationStrength)
|
||||||
|
{
|
||||||
|
$PolicyObject.JsonObject.grantControls.operator = "AND"
|
||||||
|
#$tmpObj = Get-GraphObjectFromFile $file
|
||||||
|
|
||||||
|
#$authSetting = [PSCustomObject]@{
|
||||||
|
# id = $tmpObj.grantControls.authenticationStrength.id
|
||||||
|
#}
|
||||||
|
#$PolicyObject.JsonObject.grantControls.authenticationStrength = $authSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
if($PolicyObject.JsonObject.sessionControls.disableResilienceDefaults -eq $false)
|
||||||
|
{
|
||||||
|
$PolicyObject.JsonObject.sessionControls.disableResilienceDefaults = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||||
|
{
|
||||||
|
$ids = @()
|
||||||
|
foreach($id in ($PolicyObject.JsonObject.conditions.users.includeGroups + $PolicyObject.JsonObject.conditions.users.excludeGroups))
|
||||||
|
{
|
||||||
|
if($id -in $ids) { continue }
|
||||||
|
elseif($id -eq "GuestsOrExternalUsers") { continue }
|
||||||
|
elseif($id -eq "All") { continue }
|
||||||
|
elseif($id -eq "None") { continue }
|
||||||
|
|
||||||
|
$ids += $id
|
||||||
|
Add-GraphMigrationObject $id "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach($id in ($PolicyObject.JsonObject.conditions.users.includeUsers + $PolicyObject.JsonObject.conditions.users.excludeUsers))
|
||||||
|
{
|
||||||
|
if($id -in $ids) { continue }
|
||||||
|
elseif($id -eq "GuestsOrExternalUsers") { continue }
|
||||||
|
elseif($id -eq "All") { continue }
|
||||||
|
elseif($id -eq "None") { continue }
|
||||||
|
|
||||||
|
$ids += $id
|
||||||
|
Add-GraphMigrationObject $id "users" "User" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class ConditionalAccessObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
ConditionalAccessObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
ConditionalAccessObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "ConditionalAccessType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Authentication Strengths
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Authentication Strengths
|
||||||
|
class AuthenticationStrengthsType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
AuthenticationStrengthsType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||||
|
$this._PolicyName = "Authentication Strengths"
|
||||||
|
$this._ID = "AuthenticationStrengths"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._API = "identity/conditionalAccess/authenticationStrengths/policies"
|
||||||
|
$this._ImportOrder = 45
|
||||||
|
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||||
|
$this._ObjectClass = "AuthenticationStrengthObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
$this._TopItems = 0
|
||||||
|
$this._Icon = "ConditionalAccess"
|
||||||
|
$this._HasPageSizeSupport = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.Object.policyType -ne "custom")
|
||||||
|
{
|
||||||
|
Write-Log "Built-in Authentication Strength objects cannot be imported" 2
|
||||||
|
@{ "Import" = $false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class AuthenticationStrengthObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
AuthenticationStrengthObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
AuthenticationStrengthObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "AuthenticationStrengthsType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Authentication Context
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Authentication Context
|
||||||
|
class AuthenticationContextType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
AuthenticationContextType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||||
|
$this._PolicyName = "Authentication Context"
|
||||||
|
$this._ID = "AuthenticationContext"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._HasModified = $false
|
||||||
|
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||||
|
$this._SupportsNameFilter = $false
|
||||||
|
$this._API = "identity/conditionalAccess/authenticationContextClassReferences"
|
||||||
|
$this._PropertiesToRemove = @("@odata.type")
|
||||||
|
$this._SkipRemoveProperties = @('Id')
|
||||||
|
$this._ImportOrder = 46
|
||||||
|
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||||
|
$this._ObjectClass = "AuthenticationContextObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
$this._TopItems = 0
|
||||||
|
$this._Icon = "ConditionalAccess"
|
||||||
|
$this._HasPageSizeSupport = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class AuthenticationContextObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
AuthenticationContextObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
AuthenticationContextObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "AuthenticationContextType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Authentication Context
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Authentication Context
|
||||||
|
class NamedLocationType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
NamedLocationType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||||
|
$this._PolicyName = "Named Locations"
|
||||||
|
$this._ID = "NamedLocations"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._API = "identity/conditionalAccess/namedLocations"
|
||||||
|
# Entra object - no roleScopeTagIds in the Graph schema.
|
||||||
|
$this._ScopeTagProperty = ""
|
||||||
|
$this._ImportOrder = 50
|
||||||
|
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
|
||||||
|
$this._ObjectClass = "NamedLocationObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
$this._HasPageSizeSupport = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class NamedLocationObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
NamedLocationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
NamedLocationObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "NamedLocationType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Terms of use
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Terms of use
|
||||||
|
class TermsOfUseType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
TermsOfUseType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
|
||||||
|
$this._PolicyName = "Terms of use"
|
||||||
|
$this._ID = "TermsOfUse"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._HasModified = $false
|
||||||
|
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
|
||||||
|
$this._SupportsNameFilter = $false
|
||||||
|
$this._API = "identityGovernance/termsOfUse/agreements"
|
||||||
|
# Entra object - no roleScopeTagIds in the Graph schema.
|
||||||
|
$this._ScopeTagProperty = ""
|
||||||
|
$this._ImportOrder = 75
|
||||||
|
$this._Expand = "files"
|
||||||
|
$this._QueryList = "?`$expand=files"
|
||||||
|
$this._Permissions = @("Agreement.ReadWrite.All")
|
||||||
|
$this._ObjectClass = "TermsOfUseObject"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
$pkgPath = Get-SettingValue "IntuneAppPackagesFolder"
|
||||||
|
|
||||||
|
if(-not $pkgPath -or [IO.Directory]::Exists($pkgPath) -eq $false)
|
||||||
|
{
|
||||||
|
Write-Log "Intune app directory is either missing or does not exist" 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# The agreement document is resolved in this order, per localization:
|
||||||
|
# 1. fileData.data already on the object - an export carries the PDF
|
||||||
|
# inline, fetched per localization by the sub-resource contract.
|
||||||
|
# 2. <fileName> next to the exported json (FileInfo), then in the app
|
||||||
|
# packages folder - for a json that was exported without the data.
|
||||||
|
# 3. The source object, for an in-memory COPY (below).
|
||||||
|
# Refusing rather than proceeding matters: an agreement created without
|
||||||
|
# its document lists fine but /file, /files and /file/localizations all
|
||||||
|
# 404, and a later list with $expand=files returns 500 for the WHOLE
|
||||||
|
# collection. Three of those were found in the test tenant on 2026-09-06
|
||||||
|
# and had to be deleted by hand.
|
||||||
|
#
|
||||||
|
# An earlier version of this block required the PDF on disk whenever
|
||||||
|
# FileInfo was set and ignored the embedded data. That only held together
|
||||||
|
# because Clone() used to drop FileInfo, so an import from disk never
|
||||||
|
# reached it with FileInfo populated. Once Clone() kept FileInfo, every
|
||||||
|
# import of an export with an inline PDF was refused.
|
||||||
|
$hasData = { param($f) ($f.PSObject.Properties['fileData'] -and $f.fileData -and
|
||||||
|
$f.fileData.PSObject.Properties['data'] -and $f.fileData.data) }
|
||||||
|
|
||||||
|
if($PolicyObject.FileInfo) {
|
||||||
|
foreach($file in $PolicyObject.Object.Files)
|
||||||
|
{
|
||||||
|
if(& $hasData $file) { continue }
|
||||||
|
|
||||||
|
$pdfFile = $null
|
||||||
|
if($PolicyObject.FileInfo.Directory.FullName)
|
||||||
|
{
|
||||||
|
$pdfFile = [IO.Path]::Combine($PolicyObject.FileInfo.Directory.FullName, "$($file.fileName)")
|
||||||
|
}
|
||||||
|
if(($null -eq $pdfFile -or [IO.File]::Exists($pdfFile) -eq $false) -and $pkgPath)
|
||||||
|
{
|
||||||
|
$pdfFile = [IO.Path]::Combine($pkgPath, "$($file.fileName)")
|
||||||
|
}
|
||||||
|
if($pdfFile -and [IO.File]::Exists($pdfFile))
|
||||||
|
{
|
||||||
|
Write-Log "Add file data: $pdfFile"
|
||||||
|
$bytes = [IO.File]::ReadAllBytes($pdfFile)
|
||||||
|
$file | Add-Member -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = [Convert]::ToBase64String($bytes) }) -Force
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Log "Terms of use file $($file.fileName) not found next to the export or in the app packages folder" 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$touFiles = @($PolicyObject.Object.Files)
|
||||||
|
$touMissing = @($touFiles | Where-Object { -not (& $hasData $_) })
|
||||||
|
|
||||||
|
if($touFiles.Count -gt 0 -and $touMissing.Count -gt 0)
|
||||||
|
{
|
||||||
|
$touSource = $PolicyObject._ClonedFromObject
|
||||||
|
if($touSource -and $touSource.Id)
|
||||||
|
{
|
||||||
|
Write-Log "Terms of use '$($PolicyObject.Name)': agreement file(s) not loaded - fetching them from the source object"
|
||||||
|
$touTokenId = 0
|
||||||
|
if($touSource.PSObject.Properties['_TokenId'] -and $null -ne $touSource._TokenId) {
|
||||||
|
$touTokenId = [int]$touSource._TokenId
|
||||||
|
}
|
||||||
|
elseif($PolicyObject.PSObject.Properties['_TokenId'] -and $null -ne $PolicyObject._TokenId) {
|
||||||
|
$touTokenId = [int]$PolicyObject._TokenId
|
||||||
|
}
|
||||||
|
try { Invoke-PolicySubresourceFetch -Policies @($touSource) -TokenId $touTokenId | Out-Null }
|
||||||
|
catch { Write-LogError "Failed to fetch terms of use file data from the source object" $_.Exception }
|
||||||
|
|
||||||
|
foreach($touFile in $touMissing)
|
||||||
|
{
|
||||||
|
$srcFile = @($touSource.Object.Files) | Where-Object { $_.id -eq $touFile.id } | Select-Object -First 1
|
||||||
|
if($srcFile -and $srcFile.PSObject.Properties['fileData'] -and $srcFile.fileData -and $srcFile.fileData.data)
|
||||||
|
{
|
||||||
|
$touFile | Add-Member -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = $srcFile.fileData.data }) -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$touMissing = @($touFiles | Where-Object { -not (& $hasData $_) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($touFiles.Count -eq 0 -or $touMissing.Count -gt 0)
|
||||||
|
{
|
||||||
|
Write-Log "Terms of use '$($PolicyObject.Name)': the agreement document is missing and could not be loaded from the source. The object will not be imported - creating it would leave an agreement with no document, which breaks the whole Terms of Use list." 2
|
||||||
|
return @{"Import" = $false}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
|
||||||
|
{
|
||||||
|
if(-not $PathToFile) { return }
|
||||||
|
$fi = [IO.FileInfo]$PathToFile
|
||||||
|
# File binary was fetched into fileData.data by TermsOfUseObject's
|
||||||
|
# sub-resource contract during hydration. Write each to disk; no re-fetch.
|
||||||
|
foreach($file in @($PolicyObject.Object.Files))
|
||||||
|
{
|
||||||
|
$data = $null
|
||||||
|
if($file.PSObject.Properties['fileData'] -and $file.fileData -and $file.fileData.PSObject.Properties['data']) {
|
||||||
|
$data = $file.fileData.data
|
||||||
|
}
|
||||||
|
if($data)
|
||||||
|
{
|
||||||
|
Write-Log "Save file $($file.FileName)"
|
||||||
|
$fileName = [IO.Path]::Combine($fi.DirectoryName, "$($file.FileName)")
|
||||||
|
[IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class TermsOfUseObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
# Maps each in-flight file-data request key back to its file object so
|
||||||
|
# ApplySubResourceBatchResult can attach the fetched binary.
|
||||||
|
Hidden [Hashtable]$_SubResourceState = $null
|
||||||
|
|
||||||
|
TermsOfUseObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
TermsOfUseObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "TermsOfUseType")
|
||||||
|
|
||||||
|
# Agreement file binaries aren't in the body; fetch each localization's
|
||||||
|
# fileData via the sub-resource contract.
|
||||||
|
$this._HasSubResourceBatch = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# The agreements/<id>/file/localizations('<fid>')/fileData/data API lives
|
||||||
|
# ONLY here — was previously duplicated in Sync-BulkExportTermsOfUseFiles
|
||||||
|
# (Internal/PolicyHydrateExtras.ps1) and TermsOfUseType.PostExportCommand.
|
||||||
|
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
|
||||||
|
{
|
||||||
|
if($Phase -ne 1) { return @() }
|
||||||
|
$this._SubResourceState = @{}
|
||||||
|
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
foreach($file in @($this.Object.Files)) {
|
||||||
|
if(-not $file.id) { continue }
|
||||||
|
$existing = $null
|
||||||
|
if($file.PSObject.Properties['fileData'] -and $file.fileData -and $file.fileData.PSObject.Properties['data']) {
|
||||||
|
$existing = $file.fileData.data
|
||||||
|
}
|
||||||
|
if($existing) { continue }
|
||||||
|
$key = "toufile_$($file.id)"
|
||||||
|
$this._SubResourceState[$key] = $file
|
||||||
|
[void]$reqs.Add([PSCustomObject]@{
|
||||||
|
Key = $key
|
||||||
|
Url = "agreements/$($this.Id)/file/localizations('$($file.id)')/fileData/data"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return $reqs.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
|
||||||
|
{
|
||||||
|
if($Phase -ne 1 -or $null -eq $this._SubResourceState -or -not $this._SubResourceState.ContainsKey($Key)) { return @() }
|
||||||
|
$file = $this._SubResourceState[$Key]
|
||||||
|
|
||||||
|
$data = $null
|
||||||
|
if($Body -is [string]) { $data = $Body }
|
||||||
|
elseif($Body -and $Body.PSObject.Properties['value']) { $data = $Body.value }
|
||||||
|
elseif($Body -and $Body.PSObject.Properties['data']) { $data = $Body.data }
|
||||||
|
|
||||||
|
if($data) {
|
||||||
|
if($file.PSObject.Properties['fileData'] -and $file.fileData) {
|
||||||
|
if($file.fileData.PSObject.Properties['data']) { $file.fileData.data = $data }
|
||||||
|
else { $file.fileData | Add-Member -MemberType NoteProperty -Name 'data' -Value $data -Force }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Add-Member -InputObject $file -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = $data }) -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
|||||||
|
#ImportOrder 205
|
||||||
|
|
||||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
|
||||||
|
class EndpointSecurityGroup : IntunePolicyGroupBase
|
||||||
|
{
|
||||||
|
EndpointSecurityGroup() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._ID = "EndpointSecurity"
|
||||||
|
$this._Name = "Endpoint Security"
|
||||||
|
$this._Icon = "EndpointSecurity"
|
||||||
|
$this._ExtraColumns = @("TemplateFamily=Parent type") # two of three members supply it
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Intents
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Intents
|
||||||
|
|
||||||
|
class EndpointSecurityType : IntunePolicyTypeBase
|
||||||
|
{
|
||||||
|
EndpointSecurityType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
|
||||||
|
$this._PolicyName = "Endpoint Security"
|
||||||
|
$this._APITitle = "Endpoint Security (Intents)"
|
||||||
|
|
||||||
|
$this._ID = "EndpointSecurity"
|
||||||
|
$this._API = "deviceManagement/intents"
|
||||||
|
$this._PolicyBaseName = "Intents"
|
||||||
|
$this._PropertiesToRemove = @('Settings','@OData.Type')
|
||||||
|
# Graph: "Properties not patchable specified: IsAssigned,
|
||||||
|
# IsMigratingToConfigurationPolicy, TemplateId". Settings are updated
|
||||||
|
# via the /updateSettings action, not the intent PATCH.
|
||||||
|
$this._PropertiesToRemoveForUpdate = @('isAssigned','isMigratingToConfigurationPolicy','templateId','settings')
|
||||||
|
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
|
||||||
|
$this._SubTypeColumn = "PolicyName=Type" # per-row template name (Antivirus, Firewall, ...)
|
||||||
|
$this._ExtraColumns = @("TemplateFamily=Parent type")
|
||||||
|
$this._Expand = "Settings"
|
||||||
|
$this._Icon = "EndpointSecurity"
|
||||||
|
$this._Dependencies = @("ReusableSettings")
|
||||||
|
$this._ObjectClass = "IntentObject"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]GetCompareConfig()
|
||||||
|
{
|
||||||
|
return @{
|
||||||
|
Prop = "settings"
|
||||||
|
GetKey = { param($s) "$($s.definitionId)" }
|
||||||
|
GetValue = { param($s) Get-IntentSettingValue $s }
|
||||||
|
GetCategory = { param($s) Get-IntentSettingCategory $s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden [PSCustomObject]GetTemplate($TemplateId)
|
||||||
|
{
|
||||||
|
# Tag the baseline-template cache with TenantCache_<tenantId> so it gets wiped by
|
||||||
|
# Clear-TenantCache on disconnect — previous code stored it untagged and the
|
||||||
|
# previous tenant's templates would leak across tenant switches.
|
||||||
|
$tenantId = $script:OrganizationId
|
||||||
|
$cacheId = "BaseLineTemplates_$tenantId"
|
||||||
|
$baseLineTemplates = Get-CacheObject $cacheId
|
||||||
|
if(-not $baseLineTemplates)
|
||||||
|
{
|
||||||
|
$baseLineTemplates = (Invoke-MSGraphAPI -Url "/deviceManagement/templates").Value
|
||||||
|
if($baseLineTemplates) {
|
||||||
|
Set-CacheObject $cacheId $baseLineTemplates "TenantCache_$tenantId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(-not $baseLineTemplates) { return $null}
|
||||||
|
|
||||||
|
return ($baseLineTemplates | Where-Object Id -eq $TemplateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
return (@{
|
||||||
|
"API"="deviceManagement/templates/$($PolicyObject.JsonObject.templateId)/createInstance"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
|
||||||
|
{
|
||||||
|
$this.UpdateSettings($PolicyObject, $SourceObject.JsonObject.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden UpdateSettings($PolicyObject, $Settings)
|
||||||
|
{
|
||||||
|
if(($Settings | Measure-Object).Count -eq 0) { return }
|
||||||
|
|
||||||
|
$clonedSettings = @()
|
||||||
|
$Settings | ConvertTo-Json -Depth 50 | ConvertFrom-Json | ForEach-Object { $clonedSettings += $_ }
|
||||||
|
$newSettings = ([HashTable]@{
|
||||||
|
"settings" = $clonedSettings
|
||||||
|
})
|
||||||
|
Remove-GraphPropertiesForImport $PolicyObject $newSettings.Settings -KeepProperties "@odata.type"
|
||||||
|
|
||||||
|
$response = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.id)/updateSettings" -Body ($newSettings | ConvertTo-Json -Depth 50) -Method "POST" -FullResponseObject -TokenId $PolicyObject._TokenID
|
||||||
|
if($response.Success) {
|
||||||
|
Write-Log "Settings updated successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntentObject : IntunePolicyBase
|
||||||
|
{
|
||||||
|
Hidden [PSCustomObject]$_BaselineTemplate = $null
|
||||||
|
|
||||||
|
IntentObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
IntentObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "EndpointSecurityType")
|
||||||
|
|
||||||
|
if($this._PolicyType -and $this.JsonObject.templateId) {
|
||||||
|
$this._BaselineTemplate = $this._PolicyType.GetTemplate($this.JsonObject.templateId)
|
||||||
|
|
||||||
|
if($this._BaselineTemplate) {
|
||||||
|
Add-ObjectProperty $this "BaselineTemplate" { $this._BaselineTemplate }
|
||||||
|
Add-ObjectProperty $this "TemplateFamily" { (Get-EndpointSecurityCategoryName (?: ($this.BaselineTemplate.templateSubtype -eq "none") $this.BaselineTemplate.templateType $this.BaselineTemplate.templateSubtype)) } # ToDo: Get actual language string
|
||||||
|
Add-ObjectProperty $this "Category" { $this.TemplateFamily }
|
||||||
|
Add-ObjectProperty $this "TemplateVersion" { $this.BaselineTemplate.versionInfo }
|
||||||
|
$this._PlatformName = Get-LanguageString "Platform.$($this._BaselineTemplate.platformType)" -IgnoreMissing
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this._PolicyName = ?? $this.BaselineTemplate.displayName $this._PolicyType.PolicyBaseType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-EndpointSecurityCategoryName
|
||||||
|
{
|
||||||
|
param($TemplateType)
|
||||||
|
|
||||||
|
if(-not $TemplateType)
|
||||||
|
{
|
||||||
|
Write-Log "Get-EndpointSecurityCategoryName called with empty Category" 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnString = $null
|
||||||
|
|
||||||
|
if($TemplateType.StartsWith("endpointSecurity"))
|
||||||
|
{
|
||||||
|
$TemplateType = $TemplateType.Substring(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if($TemplateType -eq "none")
|
||||||
|
{
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "accountProtection")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SecurityTemplate.accountProtection"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "antivirus")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SecurityTemplate.antivirus"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "diskEncryption")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SecurityTemplate.diskEncryption"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "endpointDetectionReponse" -or $TemplateType -eq "EndpointDetectionAndResponse")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SecurityTemplate.eDR"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "attackSurfaceReduction")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SecurityTemplate.aSR"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "attackSurfaceReduction")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SecurityTemplate.aSR"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "firewall")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SecurityTemplate.firewall"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "applicationControl")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "PolicyType.applicationControl"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "securityBaseline" -or
|
||||||
|
$TemplateType -eq "advancedThreatProtectionSecurityBaseline" -or
|
||||||
|
$TemplateType -eq "microsoftEdgeSecurityBaseline" -or
|
||||||
|
$TemplateType -eq "baseline")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "Titles.securityBaselines"
|
||||||
|
}
|
||||||
|
elseif($TemplateType -eq "enrollmentConfiguration")
|
||||||
|
{
|
||||||
|
$returnString = Get-LanguageString "SettingDetails.enrollment"
|
||||||
|
}
|
||||||
|
|
||||||
|
if([String]::IsNullOrEmpty($returnString))
|
||||||
|
{
|
||||||
|
Write-Log "Could not translate templateSubtype $TemplateType" 2
|
||||||
|
return $TemplateType
|
||||||
|
}
|
||||||
|
|
||||||
|
return $returnString
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Settings Catalog
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Settings catalog
|
||||||
|
class EndpointSecuritySettingsCatalogType : SettingsCatalogTypeBase
|
||||||
|
{
|
||||||
|
EndpointSecuritySettingsCatalogType() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
|
||||||
|
$this._ID = "EndpointSecuritySettingsCatalog"
|
||||||
|
# The template version a policy was created from - how you spot an
|
||||||
|
# antivirus or baseline policy still on a superseded template.
|
||||||
|
$this._ExtraColumns = @("Object.templateReference.templateDisplayVersion=Template version")
|
||||||
|
$this._APITitle = "Endpoint Security (Settings Catalog)"
|
||||||
|
$this._QueryList = "?`$filter=templateReference/templateFamily eq 'baseline' or templateReference/templateFamily eq 'endpointSecurityAccountProtection' or templateReference/templateFamily eq 'endpointSecurityAntivirus' or templateReference/templateFamily eq 'endpointSecurityDiskEncryption' or templateReference/templateFamily eq 'endpointSecurityEndpointDetectionAndResponse' or templateReference/templateFamily eq 'endpointSecurityAttackSurfaceReduction' or templateReference/templateFamily eq 'endpointSecurityFirewall' or templateReference/templateFamily eq 'endpointSecurityApplicationControl'"
|
||||||
|
$this._Icon = "EndpointSecurity"
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
|
||||||
|
$this._PolicyTypeOrder = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#########################################################################################
|
||||||
|
#
|
||||||
|
# Reusable Settings
|
||||||
|
#
|
||||||
|
#########################################################################################
|
||||||
|
|
||||||
|
# region Reusable Settings
|
||||||
|
class ReusableSettingsEndpointSecurityType : ReusableSettingsTypeBase
|
||||||
|
{
|
||||||
|
ReusableSettingsEndpointSecurityType() : Base()
|
||||||
|
{
|
||||||
|
([ReusableSettingsEndpointSecurityType]$this).Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
{
|
||||||
|
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
|
||||||
|
# !!! For now...Should check actual supported settingDefinitionId for Endpoint Security.
|
||||||
|
$this._QueryList = "?`$filter=settingDefinitionId ne 'linux_customcompliance_discoveryscript_reusablesetting'"
|
||||||
|
$this._ID = "ReusableSettingsEndpointSecurity"
|
||||||
|
$this._HasPlatform = $false
|
||||||
|
$this._Folder = "ReusableSettings"
|
||||||
|
$this._ObjectClass = "ReusableSettingEndpointSecurityObject"
|
||||||
|
$this._ImportOrder = 75
|
||||||
|
$this._PolicyTypeOrder = 145
|
||||||
|
$this._Icon = "EndpointSecurity"
|
||||||
|
$this._ExpandAssignmentsList = $false
|
||||||
|
$this._SupportsAssignments = $false
|
||||||
|
|
||||||
|
if($null -ne $this._PolicyGroup) {
|
||||||
|
$this._PolicyGroup.AddPolicyType($this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
|
||||||
|
{
|
||||||
|
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementReusablePolicySetting") { return $false }
|
||||||
|
|
||||||
|
# !!! For now...Should check actual supported settingDefinitionId for Endpoint Security. deviceConfigurationScripts
|
||||||
|
if($PolicyObject.settingDefinitionId -ne 'linux_customcompliance_discoveryscript_reusablesetting') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Class ReusableSettingEndpointSecurityObject : ReusableSettingsObjectBase
|
||||||
|
{
|
||||||
|
ReusableSettingEndpointSecurityObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
|
||||||
|
|
||||||
|
ReusableSettingEndpointSecurityObject() : Base()
|
||||||
|
{
|
||||||
|
$this.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden Init()
|
||||||
|
{
|
||||||
|
$this._PolicyType = (Get-SingletonObject "ReusableSettingsEndpointSecurityType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user