Osy#betaRun it on Osyrin: osyrin.com

Reference / API

Endpoints a pushing provider calls — Query, ContentType, OidcIssuer

new Endpoint(Fn) { Query = ["validationToken"], ContentType = "text/plain", OidcIssuer = "https://accounts.google.com" } · Request.Query(name) · Request.OidcEmail(audience) · Request.OidcClaim(audience, name) · TestOidc.Bearer(issuer, audience) · TestOidc.Claim(name, value)

A provider that PUSHES to your app dictates its own handshake, and three endpoint declarations meet the common ones. `Query` names the query-string parameters the function may read with `Request.Query(name)` (Microsoft Graph's `validationToken`). `ContentType` answers the function's `string` as the whole response body instead of the JSON envelope (Graph wants the token echoed as `text/plain`). `OidcIssuer` has the PLATFORM verify the request's OpenID Connect bearer token — Google Cloud Pub/Sub signs every push with one — and `Request.OidcEmail(audience)` answers who it was issued to, for that audience only; `Request.OidcClaim(audience, name)` answers any other claim of it — a CI job's repository, branch and workflow. The token itself never reaches your code.

preview2 examples compiled by CIapihttpwebhooksecurity

Summary#

Three more things an endpoint may declare — each a grant, like Raw and Headers on [[api-rest#raw-and-headers]]:

new Endpoint(GraphHook) { Method = HttpMethod.Post, Path = "/graph", Raw = true,
                          Query = ["validationToken"], ContentType = "text/plain" }
new Endpoint(PubSubHook) { Method = HttpMethod.Post, Path = "/pubsub", Raw = true,
                           OidcIssuer = "https://accounts.google.com" }

Signature#

// on the endpoint
Query       = ["name", …]            // query-string parameters the function may read — matched EXACTLY
ContentType = "text/plain"           // the function's `string` IS the response body, with this Content-Type
OidcIssuer  = "https://issuer"       // the platform verifies the request's bearer token against this issuer

// in the function
string? Request.Query(string name)        // that parameter's value, or null when the request carried none
string? Request.OidcEmail(string audience) // the verified token's email — when it was issued FOR this audience
                                           // and its email is verified; null otherwise
string? Request.OidcClaim(string audience, string name)  // any one-string claim of that verified token, for this
                                                         // audience; null otherwise

// in a test only
TestOidc.Bearer(string issuer, string audience)   // from here on, the request carried a verified token from `issuer`
TestOidc.Claim(string name, string value)         // …carrying this claim

Description#

Query — a handshake in the query string#

Microsoft Graph proves an endpoint is yours by POSTing ?validationToken=<token> to it and expecting the token back. Meta's webhooks send hub.challenge the same way. An endpoint binds only its path and its JSON body, so a query parameter is DECLARED, exactly as a header is: Query = ["validationToken"], read with Request.Query("validationToken"). A parameter the endpoint did not declare is not readable — the compiler refuses the read and names what IS declared. Names are matched exactly (a query string's names are case-sensitive, unlike headers).

ContentType — answer in the sender's format#

Every endpoint answers in the JSON envelope ([[api-rest#envelope]]) — so a function returning string answers {"result": "…"}. A handshake that compares the body byte for byte needs the bare text. ContentType = "text/plain" makes the function's string the WHOLE body, with that Content-Type. The function must return string; anything else is refused at compile time.

OidcIssuer — a signed push, verified by the platform#

Google Cloud Pub/Sub (and Cloud Tasks, Cloud Scheduler, Eventarc) authenticate a push with an OpenID Connect token in Authorization: Bearer …. That header can never be declared: it is how a caller authenticates to the platform, and an app that could read it could replay its callers' credentials. So the platform verifies it instead:

  • the signature against the keys the declared issuer publishes (<issuer>/.well-known/openid-configuration, fetched once and again when they rotate);
  • iss is the declared issuer; the token has not expired.

Your function then asks Request.OidcEmail(audience): the token's email if — and only if — it was issued FOR that audience (a token Google minted for somebody else's service never answers for yours) and the issuer verified the email. Compare it with the account you expect; anything else is null. The token itself never reaches Osy#.

The platform logs every token it could not verify, with the reason ("the request carried no bearer token", "signed with a key the issuer does not publish", "expired at …", "issued by …"), and every one it did, with the email and audience — so a misconfigured push subscription is found in the log rather than guessed at.

Request.OidcClaim — what else the token says#

A token states more than an email. A CI job's token — GitHub Actions signs one per job for permissions: id-token: write — names the repository, the ref it ran on, the job_workflow_ref (the workflow file and the ref it came from) and the sha. Request.OidcClaim(audience, name) answers any claim the verified token carries as ONE string, under the same rule as OidcEmail: valid, issued for that audience, or null. A claim that is a list or an object answers null too — a check compares one value. Compare every claim you rely on with what you expect; job_workflow_ref alone pins the repository, the workflow and the branch.

Testing a verified token: TestOidc#

In a .osy test there is no request, so Request.OidcEmail and Request.OidcClaim have nothing to read. TestOidc.Bearer(issuer, audience) stands in for the platform's verification from there to the end of the test body, and TestOidc.Claim(name, value) adds a claim to it (email, email_verified and sub fill the matching fields too). A second Bearer starts a new token. Both are refused outside test code, by the compiler and again at run time: in app code they would be a way to claim any identity.

[Test]
void only_our_main_branch_is_accepted() {
  TestOidc.Bearer("https://token.actions.githubusercontent.com", "builds.example.com");
  TestOidc.Claim("repository", "acme/shop");
  TestOidc.Claim("ref", "refs/heads/main");
  Assert.True(Built().FromOurMain);
  TestOidc.Bearer("https://token.actions.githubusercontent.com", "builds.example.com");
  TestOidc.Claim("repository", "acme/shop");
  TestOidc.Claim("ref", "refs/heads/feature");
  Assert.False(Built().FromOurMain);
}

Open at the transport, proven in the body#

A pushing provider has no account in your app, so these endpoints live on a RestApi with Auth = new ApiAuth { Anonymous = true } and prove the sender in the function — OidcEmail for Pub/Sub, a per-subscription secret for Graph. Answer a request that does not prove itself with an error (throw new ValidationException(…) → 400).

Examples#

class PushReceipt { public bool FromOurSubscription; }

/// Graph: echo the validation token, as the whole body.
[AllowAnonymous]
string GraphHook() {
  return Request.Query("validationToken") ?? "";
}

/// Pub/Sub: accept only a push its push subscription's service account signed, for this endpoint.
[AllowAnonymous]
PushReceipt PubSubHook() {
  var who = Request.OidcEmail("https://desk.example.com/api/rest/v1/hooks/pubsub");
  return new PushReceipt { FromOurSubscription = who == "[email protected]" };
}

app.Apis = [
  new RestApi("Hooks") {
    Route = "hooks",
    Auth  = new ApiAuth { Anonymous = true },
    Endpoints = [
      new Endpoint(GraphHook) {
        Method = HttpMethod.Post, Path = "/graph", Raw = true, Query = ["validationToken"], ContentType = "text/plain",
      },
      new Endpoint(PubSubHook) { Method = HttpMethod.Post, Path = "/pubsub", Raw = true, OidcIssuer = "https://accounts.google.com" },
    ],
  },
];
class BuildReceipt { public bool FromOurMain; }

/// GitHub Actions signs each job's token; the platform verifies it against GitHub's published keys.
[AllowAnonymous]
BuildReceipt Built() {
  var repository = Request.OidcClaim("builds.example.com", "repository");
  var branch = Request.OidcClaim("builds.example.com", "ref");
  return new BuildReceipt { FromOurMain = repository == "acme/shop" && branch == "refs/heads/main" };
}

app.Apis = [
  new RestApi("Builds") {
    Route = "builds",
    Auth  = new ApiAuth { Anonymous = true },
    Endpoints = [
      new Endpoint(Built) { Method = HttpMethod.Post, Path = "/finished", OidcIssuer = "https://token.actions.githubusercontent.com" },
    ],
  },
];

See also#

Related

publishing a REST API (app.Apis)

`app.Apis` publishes your app to a third party over HTTP. You never write a controller or an endpoint handler — you…

Pushed mail — Gmail through Pub/Sub, Microsoft 365 through Graph notifications

A watched mailbox that can PUSH is told of new mail the moment it lands instead of being asked every minute: Gmail…

Correlation — finding a run by a business key

Let an inbound event find its run by a key the sender already knows — a tracking number, an invoice reference, an…