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 claimDescription#
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); issis 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#
- publishing a REST API (app.Apis) — publishing endpoints,
Raw,Headers, the envelope, whouseris - Pushed mail — Gmail through Pub/Sub, Microsoft 365 through Graph notifications — the watched-mailbox push door, built on these
- Correlation — finding a run by a business key — handing a verified push to the run it is about