The credential provider, from zero
For someone who has not written COM. It explains what this code assumes so the rest of the
repository reads as engineering rather than incantation.
1. One sentence
A credential provider is a COM object Windows loads into LogonUI.exe to draw the tiles
on the sign-in screen and to hand back a serialized credential when the user submits.
2. What it is not
This is the most important section, and the old version of this repository got part of it
wrong.
It is not an authentication mechanism. Microsoft says so directly. A credential
provider collects and serializes credentials; it does not perform authentication and it
does not decide whether a credential is valid. LSA does that, afterwards. So:
- A correct SMS code with a wrong password still fails. This code never sees the password
as anything but opaque bytes, and never compares it to anything.
- Network logons, SMB, WinRM, scheduled tasks and services never reach a credential
provider at all.
- Safe mode does not load credential providers, so nothing here applies there.
It is not a complete view of who signed in. It sees what Windows routes through it, on
this machine, in interactive scenarios it registered for. That is why
supports_complete_session_audit is fixed false in the contract.
Putting the filter and the provider in one DLL does not guarantee recovery. The
previous version of this repository claimed it did: if the DLL fails to load, neither
object is created, so no filtering happens, so every tile is visible. That holds for a
load failure and only for a load failure. A DLL that loads, filters successfully, and
then faults during enumeration or serialization has already hidden the alternatives. The
claim has been removed, and installation.md section 5.5 replaces it
with a rehearsed independent route.
3. COM, the minimum
An interface is a table of function pointers. ICredentialProvider is a struct of
pointers; EtProvider is a C++ class whose vtable happens to have the right shape.
HRESULT is how everything reports. S_OK is success, S_FALSE is a meaningful
non-error (and GetUserSid uses it deliberately), E_NOTIMPL means "I do not handle
this", and anything negative is failure. Note that SUCCEEDED(S_FALSE) is true, which is
a trap worth knowing about.
IUnknown is the three methods every interface begins with. QueryInterface asks "do
you support this interface?", AddRef and Release count references, and an object
deletes itself at zero. Getting the pairing right, every time, is most of what COM
correctness means. This code uses Microsoft::WRL::ComPtr for anything held across a
function boundary, so an early return cannot skip a Release.
A CLSID names a class. Ours is {E7B4C1A2-…}. Windows looks it up under
HKLM\SOFTWARE\Classes\CLSID\{…}\InprocServer32 to find the DLL, then calls the DLL's
DllGetClassObject to get a factory, then asks the factory for an instance.
Apartment threading. Our classes register ThreadingModel = Apartment, which means
Windows calls us on one thread and serializes calls. Two consequences run through the
whole design: GetSerialization must never block, and an interface pointer Windows hands
us on that thread cannot simply be used from a worker — it has to be marshalled.
4. The interfaces this build implements
| Interface | Role |
|---|---|
ICredentialProvider | The factory for the sign-in screen: what scenario, how many tiles, tile N. Owns the field layout. |
ICredentialProviderSetUserArray | Receives the list of users Windows wants tiles for. This is what makes it a V2 provider. |
ICredentialProviderCredential2 | One tile. Owns the field values. GetSerialization is the product. |
ICredentialProviderFilter | Which other providers appear, and how an incoming remote credential is routed. |
Field descriptions belong to the provider; field values belong to the credential. That
split trips everyone up once.
5. Why this is a custom provider and not a wrapper
The previous version created Microsoft's password provider, forwarded almost every call to
it, appended two fields, and gated the result.
Microsoft discourages wrapping, and the reason is practical: the wrapper depends on the
system provider's field layout, its field count, its ordering, and its behaviour. Any of
those can change in a Windows update. When it does, the wrapper breaks silently, on
the logon screen, on Microsoft's schedule rather than yours.
So this build collects the username and password itself and packs the
KERB_INTERACTIVE_UNLOCK_LOGON blob itself. That is more code — serialization.cpp — but
it is code whose behaviour is ours, and every offset it reads out of an incoming blob is
validated before use.
6. GetSerialization, the product
Windows calls it when the user submits. What it returns decides whether the sign-in
proceeds.
| Return | Means |
|---|---|
CPGSR_RETURN_CREDENTIAL_FINISHED | Here is the credential; go authenticate it |
CPGSR_NO_CREDENTIAL_NOT_FINISHED | Not yet — keep the tile open, show this message |
CPGSR_NO_CREDENTIAL_FINISHED | Give up on this attempt |
It runs on LogonUI's UI thread. It must not block and must not wait for user input. A
blocking network call here freezes the sign-in screen until the network answers.
The flow across several calls:
- First submit. Collect the fields, start a worker, return
NOT_FINISHEDwith a
status line. The worker — not this thread — resolves the account to a SID and classifies
the path: LookupAccountName goes to a domain controller, which on a slow or
disconnected network is seconds, and seconds on this thread is a frozen logon screen.
- Worker finishes. On
require_smsthe code box appears and a challenge is
requested. On an allow, the tile asks to be auto-submitted.
- Second submit. The typed code goes to verification, again on a worker, again
NOT_FINISHED.
- Worker finishes. On
allow_mfathe tile asks to be auto-submitted. - Final call. The attempt is authorized.
AuthorizeReleasere-checks every deadline
that applies, performs the durable debit if one is owed, BeginSubmission claims the
single release, the credential is packed, and RETURN_CREDENTIAL_FINISHED comes back —
exactly once.
What gets packed, and why it is not the live fields
The identity that was authorized is captured when the attempt is created — the account,
the SID, the Windows session, the authentication package, the credential generation — and
that is what gets serialized. Re-reading the live fields at release time is how an
approval obtained for one account hands Windows a different one, and it is what the first
revision of this code did: completing MFA for CORP\alice and then receiving an incoming
RDP credential for CORP\bob released Bob's credential under Alice's approval.
So anything that could change what would be serialized moves the credential generation on,
and an authorization from an earlier generation is not an authorization: typing in the
username or password box, deselecting the tile, and an incoming credential arriving. The
session the decision was bound to is re-checked too — a decision for session 1 may not
complete a sign-in in session 3.
A path this provider does not intercept still needs a credential
ignore, log and the designated local recovery account are all dispositions where
Windows was always going to do the authenticating. They still have to produce a blob, and
the engine mints no Attempt for a local subject — so these take an explicit, single-use
local authorization carrying the precise Windows identity that was judged. Without it
the recovery account could not sign in through this provider at all: the only branch that
packed anything required an authorized Attempt, so every submission simply re-ran
validation.
7. Auto-submission, precisely
There is a widespread belief that automatic submission skips GetSerialization. **Do not
rely on it either way.** GetCredentialCount's pbAutoLogonWithDefault asks Windows to
submit the default tile, and Windows then calls GetSerialization on it.
So auto-submission here saves the user pressing Enter a second time after a code they have
already entered was verified. It is not a way to skip the gate, and the gate does not
depend on it being one. windows-acceptance.md section 5 records
the actual call sequence rather than assuming it.
The request is honoured only during GetCredentialCount — a re-enumeration, which is a
safe boundary. Never in the middle of a live authentication callback.
8. Threading and marshalling
ICredentialProviderCredentialEvents and ICredentialProviderEvents are apartment-bound.
AddRef'ing one and calling it from a worker thread is a COM rule violation that happens to
work until it does not.
This build registers them in the Global Interface Table when Windows supplies them,
and the worker retrieves a proxy valid in its own apartment. That is the documented way to
move an interface pointer between apartments, and it is why worker.h exists.
Detaching is the other half. The credential and its worker share a Session by
shared_ptr. Deselection, an UnAdvise, or a credential-generation bump sets
abandoned, and every callback checks it before and after obtaining the proxy, holding
the session's publish lock across the check and the update — checking without holding
anything leaves a window in which an abandoned worker's field state lands on a live tile.
A late answer for a superseded attempt is dropped, not applied to whatever the tile holds
now.
Three rules fall out of this, and all three were defects in the first revision:
- Nothing that can block runs on the UI thread. Not the network, not a directory
lookup, not an audit flush.
- The UI thread never mutates an object a worker is using. An abandoned
Attemptis
dropped, not reset in place. Attempt is a plain state machine with no locking of its
own, and the worker may be inside an engine call holding it; calling
OnCredentialChanged on it from a keystroke was a data race on the object deciding an
authentication.
- The tile's mutex is never held across a marshalled COM call. State is updated under
it, released, and only then is anything pushed to Windows.
Engine is not thread-safe either, and two detached workers can be inside it at once — an
abandoned attempt's worker still unwinding a network call, and its replacement. So the
host owns a mutex that serialises engine use; workers take it for the duration of their
calls, and GetSerialization takes it without waiting, because blocking the UI thread
behind a network call is the thing the worker exists to avoid.
The module is kept loaded for the worker's whole life by DllAddRef / DllRelease, so a
DLL unload cannot pull the code out from under a running thread.
Cancellation is scoped, not latched
Deselecting a tile abandons the requests that are running. It does not set a flag: one
WinHttpTransport is shared by every tile of a provider, and a sticky cancelled flag meant
that switching away from a tile and back stopped validation, SMS, verification and audit
for the rest of that provider's life. Cancel() moves an epoch forward; a request is
cancelled only if the epoch moves while it is running.
9. The remote path
For RDP with NLA, the client's credentials arrive at the target. The filter's
UpdateRemoteCredential is offered the incoming serialization and may rewrite it —
including the provider CLSID, which is how it reaches this provider rather than going
straight to the password provider. ICredentialProvider::SetSerialization then delivers
it.
Returning E_NOTIMPL from UpdateRemoteCredential and then claiming RDP support would be
a lie: the credential would never reach us. The previous version did exactly that, and the
claim has been corrected.
UpdateRemoteCredential answers the same questions Filter does, because it is the
same authority. A filter that has not read a verified deployment configuration, or that
cannot tell which channel it is on, or that cannot parse the blob, abstains. So do the
ignore and log actions — the administrator asked for that path to be left to Windows,
and log claims no control at all. Only intercept and block claim a remote
credential: block, because abstaining would hand it to the password provider and
complete the sign-in the configuration forbids.
The credential is also routed to the tile whose account it names, not to the first
tile. Windows builds a tile per cached user; seeding the wrong one means the account that
gets validated and the account whose blob is held are two different people.
Two things this does not do:
- It does not protect the NLA exchange. The password reached the target's authentication
stack before any of this ran.
- It does not let saved credentials through. The incoming blob is retained opaque and
released only if and when the attempt is authorized. Auto-logon on selection is refused,
so the user is prompted at the target — which breaks the seamless saved-credential
experience, and is exactly what MFA requires.
9a. Logon versus unlock
CPUS_LOGON does not mean "a new sign-in". Microsoft documents that from Windows 10
onward it covers workstation unlock as well, so the usage scenario alone cannot make the
contract's logon/unlock distinction — and that distinction decides which Core policy and
which scenario-scoped grant apply, and what the audit says.
The operation therefore comes from the session: a session that already has an
interactive user is being unlocked, whatever the scenario says.
SessionClassification::operation_from_session_facts records whether that question could
actually be answered, so a report never claims a distinction it did not make.
10. GetUserSid and the Other-user tile
For a tile associated with a user, return that user's SID. For a tile that is not — the
domain "Other user" tile — return S_FALSE with a null SID. That is the documented way to
say "this tile is not about a particular person", and it is what associates it with an
empty user slot rather than with somebody.
11. Reading order
| File | What it shows |
|---|---|
cp/dllmain.cpp | How Windows finds and loads us; the class factory. Short, and nothing in it does real work. |
cp/provider.cpp | Tiles, the field layout, the user array, and where auto-submission is honoured |
cp/credential.cpp | One tile. GetSerialization at the bottom is the product. Read it last and slowly. |
cp/serialization.cpp | Packing and unpacking KERB blobs, and every offset check on an incoming one |
cp/worker.cpp | Getting off the UI thread, and back, safely |
cp/filter.cpp | Provider visibility, and the remote routing that makes RDP possible |
windows/tests/suite_windows_callbacks.cpp | The callback behaviour that has automated coverage, run in an ordinary process: nothing registered, no LSA, no request |