Repository navigation
Description
Description
On the OAuth path, -Credential holds the client secret for Entra ID. It is not removed before the request to the Omada endpoint, so it stays in the parameter set handed to Invoke-RestMethod as a NetworkCredential. If the Omada endpoint answers 401 with a WWW-Authenticate challenge, .NET's HttpClientHandler answers it with that credential — sending the client secret to a party that should never see it.
OmadaWeb.PS/Private/Set-RequestParameter.ps1:30 lists the parameters stripped before the request. Credential is not among them, and Invoke-OAuth2Authentication does not remove it after using it to obtain the token.
Evidence
Asked of the built module — Set-RequestParameter on an OAuth context returns Uri, Headers and Credential, and that credential's password is the client secret:
Parameters that would be splatted onto Invoke-RestMethod:
Credential
Headers
Uri
RESULT: -Credential SURVIVES into the resource request.
Its password is the OAuth client secret: 'super-secret-client-secret'
Its user name is the client id: '22222222-2222-2222-2222-222222222222'
And against a listener that challenges, the credential is sent — replacing the bearer token rather than accompanying it:
CASE: server DOES challenge (401 + WWW-Authenticate: Basic)
request 1: Bearer some.jwt.token
request 2: Basic MjIyMjIyMjIt... <-- decoded: <client id>:super-secret-client-secret
CASE: server does NOT challenge (plain 200)
request 1: Bearer some.jwt.token
The challenge/response happens inside a single Invoke-RestMethod call at the HttpClient layer, so no module-level retry is needed to reach it.
Steps to reproduce
- Call
Invoke-OmadaRestMethod -AuthenticationType OAuth -EntraIdTenantId <tenant> -Credential <client id / secret>against an endpoint that answers401withWWW-Authenticate: Basic. - Observe a second request carrying
Authorization: Basic <base64 of client id:client secret>.
When it bites
Only the client-secret form is affected — the three certificate forms pass no -Credential. It needs the Omada endpoint to issue a challenge, so the realistic trigger is the token being rejected: expired, wrong audience, missing app role. In other words the secret is offered precisely when something is already wrong.
Over HTTPS nothing is exposed on the wire, and Basic is base64 rather than encryption — anything terminating TLS (the server, a proxy, a WAF, an access log that records request headers) reads it directly. This is a credential-scope violation rather than a live leak: a secret scoped to Entra ID is offered to an unrelated service.
Expected behavior
The credential authenticates this module to Entra ID and should not travel any further. Invoke-OAuth2Authentication removes it from the bound parameters once the token is in hand:
$BoundParams['Headers']['Authorization'] = "Bearer {0}" -f $AccessToken
$BoundParams.Remove('Credential') | Out-NullNot by adding Credential to Set-RequestParameter's exclusion list — that would break Invoke-WindowsAuthentication, where forwarding the credential is the NTLM/Negotiate mechanism. The codebase already contains the mirror image of the proposed fix: Invoke-WindowsAuthentication.ps1:14-15 removes any Authorization header because it conflicts with its mechanism. The OAuth path should remove the credential because it conflicts with its.
Basic and Integrated are unaffected: Invoke-BasicAuthentication builds its own header from a credential intended for that server, and Invoke-IntegratedAuthentication uses UseDefaultCredentials.
Suggested tests
- The OAuth path hands no
CredentialtoInvoke-RestMethod. - The
Windowspath still does, so the fix cannot later be over-applied to every authentication type.
Additional context
Found while writing the service-principal canary (#115), by reading the parameter path rather than by a failing test — the canary's loopback stand-in never challenges, so it would not catch this. Affects the client-secret form of the unattended authentication added in #71.
Activity