1. RDP Guard
Authyo Docs
  • ๐Ÿถ Walk through Authyo
  • Error Codes
  • About Pricing
  • APIs
    • Send OTP
      GET
    • Send OTP
      POST
    • Verify OTP
      GET
    • Verify Token
      POST
    • Revoke User Session
      POST
  • Web SDKs
    • JavaScript
    • PHP
    • AngularJS Examples
    • TypeScript Examples
    • React JS
    • Vue JS Examples
  • 2FA SDK
    • JavaScript
    • AngularJS Examples
    • PHP
    • React JS
    • TypeScript Examples
    • Vue JS Examples
  • Native/Mobile SDKs
    • Flutter
  • Integration
    • Google Sheet
    • Firebase
    • Authyo - Make
  • Session Management
    • Introductions
    • JWT Token
  • Notifications
    • Notifications Order
    • Notificatoin Invoice
  • RDP Guard
    • RDP Guard : Windows
    • RDP Guard : Linux (SSH)
    • RDP Guard : Step-Aside on Lapse / Deactivation
    • RDP Guard : Linux - Install & Test Guide
    • Authyo RDP Guard for Windows โ€” What's New (v1.0.63)
    • Authyo RDP Guard for Linux (SSH) โ€” What's New (v1.1.6)
  1. RDP Guard

RDP Guard : Step-Aside on Lapse / Deactivation

Design: Step-Aside on Lapse / Deactivation (RDP Guard)#

Status: IMPLEMENTED 2026-07-02 (v1.0.42). See "Implementation map" at the bottom.
Original status: DRAFT for review.
Date: 2026-07-02
Owner decision: jasmin โ€” after Block's paid period ends, OR after a server is
Deactivated, the Gatekeeper must STOP requiring OTP and let the user sign in with
plain Windows username + password. Disable (kill-switch) is UNCHANGED.

1. Problem & goal#

Today the agent always enforces OTP regardless of license/seat state; only the
kill-switch (disabled) denies. Customers who Block (cancel) or Deactivate a server
expect the guard to "step aside" so they aren't stuck entering OTPs for a server they
no longer pay for / no longer manage with the guard.
Goal: in the "step-aside" states, the login gate lets the user in with the normal
Windows credential (no OTP), on BOTH console and RDP, WITHOUT locking anyone out.
Explicitly accepted trade-off (owner sign-off): this removes MFA from a server on
non-payment / seat-free. Previously rejected as a security/revenue regression
(Gatekeeper.cs:150-157). This design implements the owner's reversal of that stance.

2. The three portal actions (must stay distinct)#

ActionServer callSetsPost-change login (TARGET)
Block (cancel at period end)CrudLicense(flag=2) โ†’ status='activeuntilperiodend', cancelatperiodend=trueperiod-endUntil currentPeriodEnd: OTP as normal. After: step-aside (password only)
Deactivate (free seat)DeactivateInstall โ†’ activation Removed/deactivatedseat freedStep-aside immediately (password only)
Disable (kill-switch)SetServerDisabled โ†’ disabled=truekill-switchUNCHANGED โ€” hard lockout "sign-in blocked"
Reference: Authyo.BusinessTire/GatekeeperLicense.cs:36,258;
Authyo.UI/Controllers/GatekeeperController.cs:350,551;
Database/Gatekeeper/01_gatekeeper_license_schema.sql:130-155.

3. State definitions (how the agent detects "step-aside")#

The heartbeat already returns everything needed โ€” NO server change required
(AuthyoRdpGuardController.cs:172-191, LicenseModels.cs:72-84):
licenseStatus โˆˆ {active, activeuntilperiodend, suspended, lapsed, cancelled}
activationStatus โˆˆ {active, deactivated, โ€ฆ} (portal "Removed" โ†’ deactivated)
currentPeriodEnd (timestamp)
disabled (kill-switch โ€” untouched by this design)
The agent already caches these on each authoritative heartbeat
(LicenseManager.cs:198-208): LastLicenseStatus, LastPeriodEndUnix, and it even
sets Activated=false when activationStatus=="deactivated".
Define IsStepAside(license, now) = true when NOT disabled AND any of:
1.
activationStatus == "deactivated" (Deactivated / seat freed), OR
2.
licenseStatus โˆˆ {lapsed, suspended, cancelled} (period already ended), OR
3.
licenseStatus == "activeuntilperiodend" AND LastPeriodEndUnix > 0 AND now > LastPeriodEndUnix
(Blocked and the paid period has now elapsed โ€” belt-and-suspenders in case the
server hasn't yet re-labelled the row from activeuntilperiodend โ†’ lapsed).
disabled ALWAYS wins โ†’ kill-switch still hard-denies (checked first, unchanged).
Open question O1: should suspended (an involuntary billing failure, e.g. card
declined mid-period) step aside, or only voluntary Block/Deactivate? Voluntary =
{deactivated, lapsed-after-Block, cancelled}. Recommend treating suspended the
same as lapsed (step aside) for simplicity, but flag for owner.

4. Two code sites must change together (or it's a lockout)#

The gate and the credential-provider FILTER live in different processes and BOTH
must agree, or RDP breaks:

4a. The gate (Gatekeeper.cs) โ€” return Allow instead of enforce-OTP#

Gatekeeper.cs:150-171 currently only logs the lapse and falls through to OTP.
Change: when IsStepAside, return BeginResult.Allowed("license stepped aside โ€” no OTP")
BEFORE the contact-pick / OTP-send path. Kill-switch check at :98-102 stays first
and unchanged.

4b. The RDP filter (OTPCredentialProviderFilter.cpp) โ€” un-hide the password tile#

THIS is the lockout-critical half. Today :132-145 hides ALL non-Authyo tiles on
RDP unconditionally, and UpdateRemoteCredential:150-169 returns E_NOTIMPL. If 4a
returns Allow but the filter still hides the password tile, an RDP user sees NO usable
tile โ†’ total lockout โ€” worse than today.
Change: when IsStepAside, make the filter behave like it already does on console
(pass-through). Concretely:
In Filter(...): add an IsStepAside() check that early-returns S_OK BEFORE the
suppress loop (same shape as the existing if (!IsRemoteSession()) return S_OK; at
:133). The password/PIN/picture tiles reappear.
In UpdateRemoteCredential(...): when IsStepAside, return the default/inbox
behavior instead of E_NOTIMPL so mstsc-supplied passwords complete (needed for a
smooth RDP password login). Verify the exact non-E_NOTIMPL return with a Server
2022 RDP test โ€” this is the riskiest line.

4c. Console (Gatekeeper.cs / SetUsageScenario) โ€” already safe#

Console already shows the password tile alongside ours (:96-101). Step-aside on
console = the gate returns Allow and the user picks the password tile. No filter change
needed for console. (If our tile also shouldn't appear at all in step-aside, optionally
hide it via SetUsageScenario โ€” nice-to-have, not required.)

5. The hard part: the filter can't call the gate#

The filter runs inside LogonUI, a different process, and today reads only
GkConfigPresent + IsRemoteSession โ€” it never touches license state. To decide
IsStepAside, the filter needs the cached license state.
Options (pick one โ€” Open question O2):
(A) Read the cached config file directly in the filter. The agent already writes
LastLicenseStatus, LastPeriodEndUnix, Activated, Disabled to config via
ConfigManager.Save on every heartbeat (LicenseManager.cs:227). The filter (C++)
reads the same fields the tile/bridge already read. Recommended โ€” reuses the
existing cache, no new IPC, and the kill-switch disabled flag is ALREADY consumed
at logon from this same cache, so the plumbing exists.
(B) A small shared "step-aside" sentinel file the agent writes on heartbeat. Simpler
to read but adds a new artifact + a staleness question.
(C) Have the filter call the bridge. Heavier; the filter is meant to be minimal.
Recommend (A). Confirm the C++ side already has a config reader it can extend
(the tile/bridge do โ€” GkConfigPresent proves config is reachable from this module).

6. Staleness / offline behavior (must be deliberate)#

Step-aside is decided from the CACHED license state (no network in the logon hot path).
Consequences:
After Block, the server flips to lapsed at period end, but the AGENT only learns it
on its next heartbeat (throttled ~12h on Windows). So step-aside may lag the actual
period end by up to one heartbeat interval. Acceptable (fail-SECURE: keeps OTP a bit
longer, never opens early).
If the server is unreachable forever after a Deactivate, the cached deactivated
persists โ†’ stays stepped-aside. Fine.
Interaction with serverAuthoritative/blockOnAuthyoUnreachable: step-aside is a
RELAXATION (fewer restrictions), so it should be evaluated only from a FRESH
authoritative cache when serverAuthoritative is on, mirroring how exempt/trust are
gated (Gatekeeper.cs:146-202). i.e. a stale cache must NOT be able to CLAIM
step-aside and drop MFA. Open question O3: confirm step-aside requires the same
policy-freshness gate as exempt/trust when serverAuthoritative=true.

7. Linux parity (pam_authyo)#

Mirror in pam_authyo.c:932-958: when IsStepAside (same state rules, from cached
last_license_status + activation state + period end), return PAM_IGNORE (fall
through to the next PAM module = normal password auth) instead of running the OTP flow.
The heartbeat daemon already caches license status (main.c:325). No SSH "hide tile"
problem exists on Linux โ€” PAM_IGNORE cleanly hands off to password auth. Kill-switch
(license_disabled) stays a hard PAM_AUTH_ERR at :924, unchanged.

8. UX / audit#

Log a clear line at the gate: GATE_STEPASIDE reason=deactivated|lapsed|periodend user=โ€ฆ ip=โ€ฆ
so the audit trail shows WHY OTP was skipped (important given it's an MFA-off event).
Optional: a one-time info banner on the tile in step-aside ("This server's Authyo
license has ended โ€” signing in with your Windows password"). Nice-to-have.
Portal copy: the tooltips already say "keeps enforcing OTP" for Deactivate โ€” those
MUST be updated to match the new behavior, or the portal will lie. (Doc/UI task.)

9. Test matrix (runtime, Server 2022 โ€” the E_NOTIMPL change demands real RDP tests)#

StateConsoleRDPExpected
activetile + passwordOTP onlyOTP required
Blocked, before period endtile + passwordOTP onlyOTP required
Blocked, AFTER period end (lapsed)password workspassword tile visible + login worksstep-aside, no OTP
Deactivated (Removed)password workspassword tile visible + login worksstep-aside, no OTP
Disabled (kill-switch)Authyo denies (console password still there โ€” see known gap)hard lockoutunchanged
serverAuthoritative + stale cache claiming step-asideโ€”โ€”must NOT step aside (O3)
Also fixes to VERIFY separately (pre-existing, out of scope but adjacent):
Console kill-switch gap (blocked box still reachable via console password) โ€” NOT this
change; track separately.

10. Build / ship notes#

Windows: FULL build โ€” x64 + Bridge + MSI. Do NOT -SkipBridgeRebuild (MSI ships
Core.dll from the Bridge build path). Bump CP DLL FileVersion or the MSI upgrade keeps
the old tile DLL. Verify the MSI artifact, not a mid-build copy.
Linux: rebuild pam_authyo + bump deb/rpm.
No git commit (owner commits manually).

Open questions โ€” RESOLVED by owner (2026-07-02)#

O1 โ†’ YES. suspended steps aside too. Final rule: IsStepAside = NOT disabled
AND (activationStatus=="deactivated" OR licenseStatus โˆˆ {lapsed, suspended, cancelled} OR (activeuntilperiodend AND period elapsed)). One consistent rule:
any non-active, non-killswitch state โ†’ password login.
O2 โ†’ (A) Read cached config directly. The filter reads the same cached fields the
agent writes each heartbeat (LastLicenseStatus, LastPeriodEndUnix, Activated,
Disabled). Reuses the existing kill-switch plumbing. Confirm/extend the C++ config
reader the tile/bridge already use.
O3 โ†’ YES. Under serverAuthoritative, step-aside is honored ONLY from a fresh
authoritative cache โ€” same freshness gate as exempt/trust (Gatekeeper.cs:146-202,
AUTHYO_POLICY_MAX_AGE_SEC). A stale/offline cache must NOT be able to claim
step-aside and drop MFA. When serverAuthoritative is OFF (default), cached state is
sufficient.
O4 โ†’ HIDE the Authyo tile. In step-aside only the normal Windows password tile
shows (no dead OTP tile). Implement via SetUsageScenario / GkConfigPresent-style
gating so our CP reports no tile in step-aside; the filter's un-hide (4b) exposes the
inbox password tile. No informational banner.

Implementation map (v1.0.42, 2026-07-02)#

Single source of truth: LicenseSection.IsStepAside(nowUnix) in
Authyo.Gatekeeper.Core/Config/GatekeeperConfig.cs. Rule: NOT Disabled AND
(LastActivationStatus=="deactivated" OR LastLicenseStatus โˆˆ {lapsed,suspended,cancelled} OR (activeuntilperiodend AND now > LastPeriodEndUnix)).
Windows agent:
GatekeeperConfig.cs โ€” added LastActivationStatus field + IsStepAside().
ConfigManager.cs โ€” added StepAsideForCredentialUi(config, nowUnix) (adds the O3
serverAuthoritative 24h freshness gate; fails safe on error).
LicenseManager.cs โ€” heartbeat now caches LastActivationStatus.
Gatekeeper.cs โ€” gate returns BeginResult.Allowed (GATE_STEPASIDE) inside the
localShortCircuitsTrusted branch, ahead of exempt/trust (O3-safe).
Bridge/AuthyoGatekeeperBridge.{h,cpp} โ€” new GkStepAside(configPath) export.
CredentialProvider/OTPCredentialProviderFilter.cpp โ€” Filter() early-returns S_OK
(un-hides inbox password tile) on RDP when GkStepAside; UpdateRemoteCredential
returns S_OK (allows mstsc pre-fill) when GkStepAside (was always E_NOTIMPL).
CredentialProvider/OTPCredentialProvider.cpp โ€” SetUsageScenario hides our tile
(m_configPresent = present && !steppedAside).
Version bumped VERSION.txt 1.0.41 โ†’ 1.0.42 (unversioned-DLL upgrade trap).
Linux agent:
config_parser.h/.c โ€” added last_activation_status field + parse lastActivationStatus.
authyo-configure/config_writer.c โ€” persists lastActivationStatus.
authyo-gatekeeper-heartbeat/main.c โ€” caches last_activation_status from heartbeat.
pam_authyo.c โ€” pam_is_step_aside() helper; returns PAM_IGNORE inside the
local_short_circuits_trusted branch ahead of exempt/trust.
No server changes. Heartbeat already returned the needed fields.

STILL TODO (not code โ€” follow-ups)#

Portal copy is now WRONG: Deactivate/Block tooltips say "keeps enforcing OTP".
After this ships they must be updated (server-side UI task, GatekeeperController /
views). Otherwise the portal lies.
Runtime test on Server 2022 per the ยง9 matrix โ€” especially the
UpdateRemoteCredential S_OK change on real RDP (riskiest line).
Linux deb/rpm version bump to match.
Modified atย 2026-07-02 06:05:00
Previous
RDP Guard : Linux (SSH)
Next
RDP Guard : Linux - Install & Test Guide
Built with