Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion ADFSInstaller/Dialogues.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,19 @@
<Condition Action="enable">TRIGGERCHALLENGES=1</Condition>
<Condition Action="enable">ENABLEENROLL=1</Condition>
</Control>
<Control Type="Edit" Id="in_servicepass" Width="78" Height="15" X="170" Y="178" Text="Password" Property="SERVICEPASS" Help="Service password" ToolTip="Password of the service account.">
<!-- ToolTip is kept short: it maps to the MSI Control.Help column, which is capped at 50
chars (ICE03). The full explanation lives in the note_encryption text below. -->
<Control Type="Edit" Id="in_servicepass" Width="78" Height="15" X="170" Y="178" Password="yes" Text="Password" Property="SERVICEPASS" ToolTip="Stored encrypted (enc: prefix).">
<Condition Action="disable">TRIGGERCHALLENGES&lt;&gt;1</Condition>
<Condition Action="enable">TRIGGERCHALLENGES=1</Condition>
<Condition Action="enable">ENABLEENROLL=1</Condition>
</Control>
<!-- END TRIGGER CHALLENGE / SEND PASSWORD CONTROLS -->
<!-- Explain the secret-at-rest behaviour. Visible (not a tooltip) because the MSI Help
column is length-limited; this is where the actionable detail lives. -->
<Control Type="Text" Id="note_encryption" Width="110" Height="86" X="250" Y="66" NoPrefix="yes">
<Text>Note: the service account password is stored encrypted (an "enc:" prefix is added). To change it, enter a new cleartext value here, or write it into the registry without the prefix - it is encrypted on the next AD FS restart.</Text>
</Control>
<Control Type="CheckBox" Id="cbox_upn" Width="50" Height="17" X="38" Y="135" Text="Use UPN" CheckBoxValue="1" Property="USEUPN" />
<Control Type="CheckBox" Id="cbox_debuglog" Width="116" Height="17" X="38" Y="214" Text="Activate debug log" CheckBoxValue="1" Property="DEBUGLOG" />
<!-- END MAIN -->
Expand Down
5 changes: 4 additions & 1 deletion ADFSInstaller/Product.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@

<Property Id="ARPURLINFOABOUT" Value="$(var.AppURLInfoAbout)"/>
<Property Id="ARPNOREPAIR" Value="yes" Secure="yes" />
<Property Id="ARPNOMODIFY" Value="yes" Secure="yes" />
<!-- Allow Modify from the installed-apps list so admins can change configuration (e.g. the
service password) without re-downloading the MSI. The ConfigurationDlg Change flow is
already wired below. -->
<Property Id="ARPNOMODIFY" Value="no" Secure="yes" />

<Condition Message="You need to be an administrator to install this product.">Privileged</Condition>

Expand Down
17 changes: 17 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
## 2026-06-03 v1.4.0

Support for privacyIDEA 3.13 features:
* push_code_to_phone
* enroll_via_multichallenge_optional
* enroll_via_multichallenge for smartphone containers

Secret handling:
* The `service_pass` is now encrypted at rest with Windows DPAPI. Existing plaintext values (and values typed directly into the registry) are migrated to encrypted storage automatically the first time the provider reads them, with a one-time warning written to the event log.
* The installer hardens the ACL on the configuration registry key so the `service_pass` is no longer readable by non-admin users on the machine.

Other changes:
* Added more German-speaking LCIDs (de-AT, de-CH, de-LI, de-LU).
* Updated the event log location this application writes to. It now writes to the general Windows **Application** log with the source "privacyIDEAProvider". The installer repairs the stray "AD FS/Admin" classic log key that earlier versions created, and removes the event source on uninstall.
* The installer now checks for .NET Framework 4.8 and aborts with instructions if it is missing.
* The installer can now be launched in "Modify" mode from the Windows installed-apps list to change the configuration, and the service password field is masked.

## 2025-06-02 v1.3.0

**Use this version only with privacyIDEA 3.11 or higher**
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
This project builds on Stephan Traub's [original provider v1.3.8.2](https://github.com/sbidy/privacyIDEA-ADFSProvider/tree/f66100713e650d134ac50fcbd3965b71ae588d47).

## Requirements
To use the provider, the [.NET Framework 4.8](https://dotnet.microsoft.com/download/dotnet-framework/net48) is required on the target machine.
The [.NET Framework 4.8](https://dotnet.microsoft.com/download/dotnet-framework/net48) is required on the target machine. It is included in-box on Windows Server 2022 and newer, but **not** on Server 2016 (ships with 4.6.2) or Server 2019 (ships with 4.7.2) — installing the ADFS role does not add it. On those versions install .NET Framework 4.8 first (it is also commonly delivered via Windows Update). `Install.ps1` checks for it and aborts with instructions if it is missing.

## Configuration
Starting with v1.3.0, this provider *can* be used as primary authentication method in ADFS. However, ADFS will inject a form to request the username before this provider, even if it is configured as primary, which makes the experience of passkey usernameless authentication not great in ADFS.
Expand All @@ -12,6 +12,10 @@ You could then choose to add "Forms Authentication" as additional, to have the p
The provider is configured using the registry. The keys are located at `HKEY_LOCAL_MACHINE\SOFTWARE\NetKnights GmbH\PrivacyIDEA-ADFS`.
After changing the configuration, the AD FS Service has to be restarted for the changes to become active. This can be done using the PowerShell command `Restart-Service adfssrv`.

`Install.ps1` hardens the access control list on this key: by default `HKLM\SOFTWARE` grants the local *Users* group read access, which would let any non-admin on the machine read the `service_pass` in cleartext. After installation only `SYSTEM`, `Administrators`, and the AD FS service account can access the key. If you create the configuration key *after* running the installer, re-run `Install.ps1` so the ACL is applied.

The `service_pass` is additionally encrypted at rest using Windows DPAPI. You can enter it as plaintext (via the installer dialog or directly in the registry); the provider encrypts it in place the first time it reads it, replacing the value with an `enc:`-prefixed ciphertext. To change it later, simply enter a new plaintext value — it will be re-encrypted on the next read. This protects the password in registry exports and backups; it is *not* a boundary against a local administrator, who on an AD FS server already controls the token-signing key.

| Key Name | Explanation |
| ----- | ----- |
| url | The url of the privacyIDEA server. Has to include https://! |
Expand Down Expand Up @@ -44,7 +48,7 @@ Adding `"SchUseStrongCrypto"=dword:00000001` to `HKEY_LOCAL_MACHINE\SOFTWARE\Mic
and `HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft.NETFramework\v4.0.30319` fixes the problem.

## Event Log
Errors will be written to the Windows Event Log in the `AD FS/Admin` category. To get a more detailed log, activate the `debug_log` setting as explained in the next section.
Errors will be written to the Windows **Application** event log under the `privacyIDEAProvider` source. To get a more detailed log, activate the `debug_log` setting as explained in the next section.

## Debugging
Errors in the provider can be found by looking at the Windows Event Log or activating the `debug_log` setting.
Expand Down
123 changes: 123 additions & 0 deletions Tests/Fixtures/MultichallengeEnrollFixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
namespace Tests.Fixtures
{
/// <summary>
/// Captured response bodies for the enroll-via-multichallenge flow. Each constant is
/// a representative server response; the parser only inspects JSON structure, so PNG
/// payloads and long otpauth secrets are abbreviated to keep the strings readable.
/// </summary>
internal static class MultichallengeEnrollFixtures
{
/// HOTP enroll-via-multichallenge CHALLENGE — mandatory enrollment.
public const string HotpEnrollChallenge = @"{
""detail"": {
""client_mode"": ""interactive"",
""enroll_via_multichallenge"": true,
""enroll_via_multichallenge_optional"": false,
""image"": ""data:image/png;base64,STUB"",
""link"": ""otpauth://hotp/OATH0000AC38?secret=ABCDEFG"",
""message"": ""Please scan the QR code and enter the OTP value!"",
""multi_challenge"": [{
""client_mode"": ""interactive"",
""image"": ""data:image/png;base64,STUB"",
""link"": ""otpauth://hotp/OATH0000AC38?secret=ABCDEFG"",
""message"": ""Please scan the QR code and enter the OTP value!"",
""serial"": ""OATH0000AC38"",
""transaction_id"": ""18249856845542401525"",
""type"": ""hotp""
}],
""serial"": ""OATH0000AC38"",
""transaction_id"": ""18249856845542401525"",
""type"": ""hotp""
},
""result"": {""authentication"": ""CHALLENGE"", ""status"": true, ""value"": false}
}";

/// Same shape as HotpEnrollChallenge but with enroll_via_multichallenge_optional=true,
/// which is what makes the "Not Now" path eligible.
public const string HotpEnrollChallengeOptional = @"{
""detail"": {
""client_mode"": ""interactive"",
""enroll_via_multichallenge"": true,
""enroll_via_multichallenge_optional"": true,
""image"": ""data:image/png;base64,STUB"",
""link"": ""otpauth://hotp/OATH0000F6AF?secret=ABCDEFG"",
""message"": ""Please scan the QR code and enter the OTP value!"",
""multi_challenge"": [{
""client_mode"": ""interactive"",
""image"": ""data:image/png;base64,STUB"",
""link"": ""otpauth://hotp/OATH0000F6AF?secret=ABCDEFG"",
""message"": ""Please scan the QR code and enter the OTP value!"",
""serial"": ""OATH0000F6AF"",
""transaction_id"": ""08062584491116057815"",
""type"": ""hotp""
}],
""serial"": ""OATH0000F6AF"",
""transaction_id"": ""08062584491116057815"",
""type"": ""hotp""
},
""result"": {""authentication"": ""CHALLENGE"", ""status"": true, ""value"": false}
}";

/// Email enrollment, first step — server asks for the user's email address. No
/// image; the prompt itself is the entire challenge.
public const string EmailEnrollAskForAddress = @"{
""detail"": {
""client_mode"": ""interactive"",
""enroll_via_multichallenge"": true,
""enroll_via_multichallenge_optional"": false,
""image"": null,
""message"": ""Please enter your new email address!"",
""multi_challenge"": [{
""client_mode"": ""interactive"",
""image"": null,
""message"": ""Please enter your new email address!"",
""serial"": ""PIEM0000733A"",
""transaction_id"": ""02615784748381378184"",
""type"": ""email""
}],
""serial"": ""PIEM0000733A"",
""transaction_id"": ""02615784748381378184"",
""type"": ""email""
},
""result"": {""authentication"": ""CHALLENGE"", ""status"": true, ""value"": false}
}";

/// Smartphone container enrollment — uses client_mode=poll and a pia:// link.
public const string SmartphoneEnrollChallenge = @"{
""detail"": {
""client_mode"": ""poll"",
""enroll_via_multichallenge"": true,
""enroll_via_multichallenge_optional"": false,
""image"": ""data:image/png;base64,STUB"",
""link"": ""pia://container/SMPH0000D847?issuer=privacyIDEA&ttl=10"",
""message"": ""Please scan the QR code to register the container."",
""multi_challenge"": [{
""client_mode"": ""poll"",
""image"": ""data:image/png;base64,STUB"",
""link"": ""pia://container/SMPH0000D847?issuer=privacyIDEA&ttl=10"",
""message"": ""Please scan the QR code to register the container."",
""serial"": ""SMPH0000D847"",
""transaction_id"": ""17359662976761378280"",
""type"": ""smartphone""
}],
""serial"": ""SMPH0000D847"",
""transaction_id"": ""17359662976761378280"",
""type"": ""smartphone""
},
""result"": {""authentication"": ""CHALLENGE"", ""status"": true, ""value"": false}
}";

/// Server ACK after a successful cancel_enrollment=True on an optional enrollment.
public const string CancelEnrollmentAccept = @"{
""detail"": {""message"": ""Cancelled enrollment via multichallenge""},
""result"": {""authentication"": ""ACCEPT"", ""status"": true, ""value"": true}
}";

/// Server response when cancel_enrollment=True is sent against a non-optional
/// enrollment — treated as a wrong OTP, REJECT.
public const string CancelEnrollmentReject = @"{
""detail"": {""message"": ""Failed to cancel enrollment via multichallenge""},
""result"": {""authentication"": ""REJECT"", ""status"": true, ""value"": false}
}";
}
}
98 changes: 98 additions & 0 deletions Tests/Fixtures/OtpFixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
namespace Tests.Fixtures
{
/// <summary>
/// Response bodies for the plain OTP / passthru / error paths of /validate/check.
/// </summary>
internal static class OtpFixtures
{
/// Successful TOTP authentication, no challenge involved.
public const string SimpleAccept = @"{
""detail"": {
""message"": ""matching 1 tokens"",
""otplen"": 6,
""serial"": ""TOTP00001234"",
""type"": ""totp""
},
""result"": {""authentication"": ""ACCEPT"", ""status"": true, ""value"": true}
}";

/// Wrong OTP value — REJECT, no transaction id.
public const string SimpleReject = @"{
""detail"": {""message"": ""wrong otp value""},
""result"": {""authentication"": ""REJECT"", ""status"": true, ""value"": false}
}";

/// CHALLENGE response from validate/check: a token type that requires
/// challenge-response (HOTP), no enrollment image.
public const string OtpChallenge = @"{
""detail"": {
""client_mode"": ""interactive"",
""message"": ""Please enter the OTP"",
""multi_challenge"": [{
""client_mode"": ""interactive"",
""message"": ""Please enter the OTP"",
""serial"": ""OATH0000DEAD"",
""transaction_id"": ""11111111111111111111"",
""type"": ""hotp""
}],
""serial"": ""OATH0000DEAD"",
""transaction_id"": ""11111111111111111111"",
""type"": ""hotp""
},
""result"": {""authentication"": ""CHALLENGE"", ""status"": true, ""value"": false}
}";

/// Server-side error: status=false with a populated error.code / error.message.
/// Returned with HTTP 400 by the server, but the parser only sees the body.
public const string ServerError = @"{
""detail"": null,
""result"": {
""error"": {""code"": 904, ""message"": ""ERR904: User not found""},
""status"": false
}
}";

/// Final ACCEPT after answering an outstanding challenge.
public const string ChallengeCompletionAccept = @"{
""detail"": {""message"": ""Found matching challenge"", ""serial"": ""OATH0000DEAD""},
""result"": {""authentication"": ""ACCEPT"", ""status"": true, ""value"": true}
}";

/// preferred_client_mode=interactive should be translated to "otp" by the parser
/// (the field rewriting is part of PIResponse.FromJSON).
public const string PreferredClientModeInteractive = @"{
""detail"": {
""message"": ""Please enter the OTP"",
""preferred_client_mode"": ""interactive"",
""multi_challenge"": [{
""client_mode"": ""interactive"",
""message"": ""Please enter the OTP"",
""serial"": ""OATH0000DEAD"",
""transaction_id"": ""22222222222222222222"",
""type"": ""hotp""
}],
""transaction_id"": ""22222222222222222222"",
""type"": ""hotp""
},
""result"": {""authentication"": ""CHALLENGE"", ""status"": true, ""value"": false}
}";

/// preferred_client_mode=poll should be translated to "push".
public const string PreferredClientModePoll = @"{
""detail"": {
""message"": ""Please confirm on your device"",
""preferred_client_mode"": ""poll"",
""multi_challenge"": [{
""client_mode"": ""poll"",
""message"": ""Please confirm on your device"",
""serial"": ""PIPU000012AB"",
""transaction_id"": ""33333333333333333333"",
""type"": ""push""
}],
""transaction_id"": ""33333333333333333333"",
""type"": ""push""
},
""result"": {""authentication"": ""CHALLENGE"", ""status"": true, ""value"": false}
}";
}
}
Loading