Skip to content

Watch unattended service-principal authentication against the real tenant - #115

Merged
mvaneijken merged 3 commits into
mainfrom
feature/entra-service-principal-canary
Sep 23, 2026
Merged

mvaneijken merged 3 commits into
mainfrom
feature/entra-service-principal-canary

Conversation

@mvaneijken

@mvaneijken mvaneijken commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

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, 18 Its; Get-OAuthClientCertificate.Tests.ps1, 14). A mock accepts any JWT. Whether Microsoft still accepts the RS256 assertion New-OAuthClientAssertion signs — x5t in the header, aud/iss/sub/jti/exp in 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, the entra-canary environment 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:

Scenario Credential
OAuthClientSecret -Credential holding the client id and secret
OAuthCertificateStore -OAuthCertificateThumbprint, from CurrentUser\My
OAuthCertificateFile -OAuthCertificatePath — what a container uses
OAuthCertificateObject -OAuthCertificate, one the caller already holds

The tenant is shaped like a customer's

Per Omada's OAuth documentation, a real tenant holds two app registrations, and New-EntraCanaryConfiguration.ps1 now provisions the same two:

  1. The resource — the stand-in for the OpenID Connect application Omada is configured with. Carries the Application ID URI, exposes the application role, sets requestedAccessTokenVersion to 2.
  2. The OAuth client — a separate confidential registration holding the credentials, because Omada is explicit that the OIDC registration "cannot be used with client secret grants, i.e., one new application registration must be created per client application that connects to ES".

The claims asserted are the ones that documentation tells an administrator to verify: aud is the OIDC application, azp is 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.ps1 now records it (ResourceBearer, ResourceHitCount) for that purpose, and never renders it: a failing canary's diagnostic becomes a public issue.

Two reasons, neither redundant:

  1. The stand-in authorizes nothing. It answers 200 to any /api/* request, credential or not, because it is a loopback listener and not Omada. "The request succeeded" says nothing about what was presented.
  2. It is the regression guard for [Bug]: A failed OAuth token request sends an empty bearer token, so the identity provider's error surfaces as a 401 from Omada #102. Until that was fixed, a failed token request fell through to Authorization: Bearer and the user saw an unexplained 401 from Omada. Invoke-OAuthTokenRequest now stops on error and New-OAuthTokenRequestError re-throws carrying the AADSTS code — 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 notAfter from 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 -GitHubRepository would invalidate the credential GitHub holds and hand the replacement to nobody.

Verification

Check Result
./Build/build.ps1 -Task Build (incl. PSScriptAnalyzer) passes
New E2E file with no secrets set 7 skipped — stays out of normal builds, which also exclude the E2E tag
Tests/Unit/CanaryRelyingParty.Tests.ps1 31 pass (2 new, covering the recorded bearer and the empty-bearer case)
JWT payload decoding verified across all four base64url padding remainders, and returns null rather than throwing on malformed input
Bearer plumbing a real Invoke-OmadaRestMethod request's Authorization header recorded by the stand-in and read back claim by claim
Workflow YAML and its embedded PowerShell both parse
Full unit suite 979 pass, 11 fail — all 11 reproduce on an untouched main checkout (Export-OmadaCookieFile buffer test, Switch-ToManualLogin selector-fallback tests); no module source is touched by this branch

A 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.ps1 has to be run against the canary tenant with a Graph session before the new job can do anything but skip. Granting the application role needs AppRoleAssignment.ReadWrite.All on top of the existing scopes.

One thing noticed on the way, not changed here

  • -Credential is not in Set-RequestParameter's exclusion list, so on the secret scenario the client secret is forwarded to Invoke-RestMethod as a NetworkCredential alongside 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.

…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.
@mvaneijken

Copy link
Copy Markdown
Collaborator Author

/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.
@mvaneijken

Copy link
Copy Markdown
Collaborator Author

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.

@mvaneijken

Copy link
Copy Markdown
Collaborator Author

/validate

1 similar comment
@mvaneijken

Copy link
Copy Markdown
Collaborator Author

/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.
@mvaneijken

Copy link
Copy Markdown
Collaborator Author

/validate

@mvaneijken
mvaneijken merged commit 92ee2f6 into main Sep 23, 2026
5 checks passed
@mvaneijken
mvaneijken deleted the feature/entra-service-principal-canary branch September 23, 2026 05:55

This branch was previously deployed

1 inactive deployment
entra-canary — fc6337f3 Deployed Sep 22, 2026 by mvaneijken via Entra ID service principal #38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant