Repository navigation
Watch unattended service-principal authentication against the real tenant - #115
Conversation
…nant PR #71 gave the module the client-credentials grant with either a client secret or a certificate, and nothing has exercised it against Microsoft since. The unit tests mock Invoke-RestMethod, and a mock accepts any JWT - so the one thing that cannot be tested locally, whether Entra still takes the assertion New-OAuthClientAssertion signs, was not tested at all. A second job in the canary workflow now authenticates once per credential form the module documents: a client secret, a certificate from the store, one from a PFX file, and one the caller already holds. It opens no browser, so it fails independently of the sign-in canary and its alert never claims Microsoft changed a sign-in page. The tenant is shaped the way Omada's OAuth documentation describes a customer's: a resource registration standing in for the OpenID Connect application Omada is configured with, carrying the Application ID URI and exposing an application role, and a separate OAuth client holding the credentials - because Omada is explicit that the OIDC registration cannot be used with client secret grants. The claims asserted are the ones that documentation tells an administrator to verify: aud is the OIDC application, azp is the client. Every assertion is made against the token that arrived at the resource rather than against the request having completed. Invoke-OAuth2Authentication requests its token with -ErrorAction SilentlyContinue and continues with an empty bearer when none comes back, and the loopback stand-in authorizes nothing and answers 200 either way - so a canary that checked the status code would pass against a tenant issuing no tokens at all. The stand-in now records what it was presented with, which is what makes the difference visible. Both credentials expire, so the job warns thirty days out rather than waiting to go red, and rotation is a deliberate switch: a secret's value can be read only at creation, so rotating without -GitHubRepository would invalidate the credential GitHub holds and hand the replacement to nobody.
|
/validate |
…d bug The comments, the documentation and the alert text all explained why the canary asserts on the token that arrived by describing behaviour issue #102 had already removed: a failed token request falling through to 'Authorization: Bearer '. That was read off a stale branch. On main Invoke-OAuthTokenRequest stops on error and New-OAuthTokenRequestError re-throws carrying the identity provider's own error code. The assertions are unchanged and still right - the loopback stand-in authorizes nothing and answers 200 to any request, so the status code says nothing about what was presented, and reading the header is what keeps #102 fixed rather than assumed. Only the reasons given for them are corrected, so nobody reading this later concludes the module still has that defect.
|
Correction to the original description, since it asserted something false about the module. I justified the token assertions by describing behaviour that issue #102 had already removed — a failed token request falling through to \Authorization: Bearer . I read that off a stale branch; on \main, \Invoke-OAuthTokenRequest\ stops on error and \New-OAuthTokenRequestError\ re-throws carrying the identity provider's own error code. The assertions themselves are unchanged and still correct: the loopback stand-in authorizes nothing and answers 200 to any request, so reading the header is what keeps #102 fixed rather than assumed. Commit 2f86099 corrects the comments, the documentation and the alert text, and the description above is updated. |
|
/validate |
1 similar comment
|
/validate |
Provisioning the service-principal canary broke the sign-in canary, and the report sent its reader to the selector table. New-EntraCanaryConfiguration.ps1 resets the canary account's password on every run - the directory will not hand an existing one back, so there is nothing else it could do. Without -GitHubRepository the new password goes no further than the object returned to the operator while the environment keeps the old one, and the failure surfaces hours later, on a schedule, as AADSTS50126. Run 34 of the canary was exactly this: three failed assertions saying the browser never reached Microsoft, and nothing anywhere naming the provisioning run that had just moved the password out from under it. Nobody had a reason to re-run that script before; adding a second canary to it is a reason, so the hazard is worth closing rather than remembering. Two changes, neither altering what the script does to the tenant: The script now warns, while the person who caused it is still watching, that the environment holds the previous password. The sign-in canary now checks for a refused credential before anything else and says what it means - Entra ID refusing the password rules out both the selector table and the runner, and the diagnosis is a one-line re-run rather than a hunt through element ids.
|
/validate |
PR #71 gave the module unattended authentication —
-AuthenticationType OAuth, the client-credentials grant, with either a client secret or a certificate. It was never wired into the canary.Until now that code was covered only by unit tests that mock
Invoke-RestMethod(Invoke-OAuth2Authentication.Tests.ps1, 18Its;Get-OAuthClientCertificate.Tests.ps1, 14). A mock accepts any JWT. Whether Microsoft still accepts the RS256 assertionNew-OAuthClientAssertionsigns —x5tin the header,aud/iss/sub/jti/expin the payload — could not be tested that way, and so was not tested at all. The same goes for the tenant around it: a certificate credential Entra rejects, a scope that resolves to nothing, a client holding no application role.What this adds
A second job in
.github/workflows/entra-canary.yml, sharing the tenant, theentra-canaryenvironment and the alerting, and sharing nothing else. It opens no browser, so it fails independently of the sign-in canary and its alert never claims Microsoft changed a sign-in page.Four scenarios, one per credential form the module documents — they are separate runs because supplying two credentials means only one is under test:
OAuthClientSecret-Credentialholding the client id and secretOAuthCertificateStore-OAuthCertificateThumbprint, fromCurrentUser\MyOAuthCertificateFile-OAuthCertificatePath— what a container usesOAuthCertificateObject-OAuthCertificate, one the caller already holdsThe tenant is shaped like a customer's
Per Omada's OAuth documentation, a real tenant holds two app registrations, and
New-EntraCanaryConfiguration.ps1now provisions the same two:requestedAccessTokenVersionto 2.The claims asserted are the ones that documentation tells an administrator to verify:
audis the OIDC application,azpis the client. The app role is asserted too — without the grant Entra either refuses the token or, where the resource does not require assignment, issues a role-less one that authenticates nothing at Omada, and those two cases must not be confusable.Omada's own side — the user whose Username is the client id, in Impersonation service users — is out of scope: the canary has no Omada environment, and the same loopback stand-in the sign-in canary uses serves the resource here.
Why the assertions are written the way they are
Every assertion is made against the token that actually arrived at the resource.
Start-CanaryRelyingParty.ps1now records it (ResourceBearer,ResourceHitCount) for that purpose, and never renders it: a failing canary's diagnostic becomes a public issue.Two reasons, neither redundant:
200to any/api/*request, credential or not, because it is a loopback listener and not Omada. "The request succeeded" says nothing about what was presented.Authorization: Bearerand the user saw an unexplained 401 from Omada.Invoke-OAuthTokenRequestnow stops on error andNew-OAuthTokenRequestErrorre-throws carrying theAADSTScode — which the canary's diagnostic reads straight out of the exception message. This keeps that fixed rather than assumed.Credential expiry
Both a secret and a certificate expire. The job reads the certificate's
notAfterfrom the PFX it already holds and the secret's expiry from a published secret — not from Graph, which would mean giving the runner directory credentials it has no other use for — and emits a warning annotation below 30 days, so rotation happens before a red run rather than because of one.Rotation is a deliberate switch (
-RotateServicePrincipalCredential) rather than something every run does: a secret's value can be read only at creation, so rotating without-GitHubRepositorywould invalidate the credential GitHub holds and hand the replacement to nobody.Verification
./Build/build.ps1 -Task Build(incl. PSScriptAnalyzer)E2EtagTests/Unit/CanaryRelyingParty.Tests.ps1Invoke-OmadaRestMethodrequest's Authorization header recorded by the stand-in and read back claim by claimmaincheckout (Export-OmadaCookieFilebuffer test,Switch-ToManualLoginselector-fallback tests); no module source is touched by this branchA local fake token endpoint turned out to be impossible: the module refuses a non-HTTPS
-OAuthUri, deliberately, because a plain-HTTP one would send the client secret in clear text. That is correct and does not affect the canary, which uses the real endpoint via-EntraIdTenantId.Not verified here, and it cannot be: the tenant half.
New-EntraCanaryConfiguration.ps1has to be run against the canary tenant with a Graph session before the new job can do anything but skip. Granting the application role needsAppRoleAssignment.ReadWrite.Allon top of the existing scopes.One thing noticed on the way, not changed here
-Credentialis not inSet-RequestParameter's exclusion list, so on the secret scenario the client secret is forwarded toInvoke-RestMethodas aNetworkCredentialalongside the Bearer header. .NET only sends it on a 401 challenge, so nothing leaks today, but an endpoint that challenges would receive it. Also worth its own issue.