Add the 51Did creator context web example - #21
Open
jwrosewell wants to merge 12 commits into
Open
Conversation
Adds the web example for the 51Did creator context feature under examples/fodid-examples, beside the existing examples there, and lets every cloud example in this repository be pointed at another host through the endpoint environment variable the READMEs describe.
The fodid crate gains a cloud client behind an opt-in `cloud` feature, in a new `client` module, so a server that checks a 51Did never writes HTTP or key handling of its own. `DidClient` fetches the cloud's signing public keys once and caches them, picks the key in force when a given identifier was created, verifies a signature offline against that key, verifies through the cloud's verify endpoint, and redeems a sealed creator context result with the licence key, returning a typed `RedeemResult`. The resource key travels in the route of the key and verify calls and in the form body of the redeem POST, and the licence key only in that form body, so neither reaches a query string. An HTTP transport trait lets tests inject their own, and the default is the crate's existing ureq dependency. The reader itself now accepts both base64 alphabets in `FodId::from_base64`, because a page puts the identifier in a link in the URL-safe alphabet without padding, and gains `as_base64_url()` for the reverse and `date_minutes()` for the envelope date as the wire format stores it. The `license_id()` documentation says that on an identifier carrying a creator context the field holds an encrypted value only 51Degrees can read. The creator context web example's `/redeem` route now builds one `DidClient` at start-up, parses the incoming 51Did, checks its signature offline, redeems through the client, and answers the page in the cloud's own shape plus a `serverSignature` field with the outcome of its own check. The page is unchanged. The example depends on the fodid crate from this repository with the `cloud` feature, and its reqwest dependency goes because the client replaces it. Files changed: fodid/Cargo.toml, fodid/src/client.rs (new), fodid/src/fodid.rs, fodid/src/lib.rs, fodid/README.md, fodid/tests/client_tests.rs (new), fodid/tests/cloud_client.rs (new), fodid/tests/fodid_tests.rs, examples/fodid-examples/Cargo.toml, examples/fodid-examples/README.md, examples/fodid-examples/src/bin/fodid-web-creator-context.rs, both Cargo.lock files, and .github/workflows/pull-request.yml, which now tests and lints the fodid crate with every feature on. Tests: `cargo test -p fodid --all-features` went from 24 passed and 1 ignored to 69 passed and 3 ignored (5 unit, 37 client, 26 reader, 1 doctest, with the 3 ignored being the live cloud tests that need a resource key). The example crate's tests went from 4 to 8, and the full examples workspace from 85 passed and 11 ignored to 89 passed and 11 ignored. `cargo clippy --all-targets --all-features -- -D warnings`, `cargo doc` and `cargo fmt --check` are clean.
A cloud that has not taken the creator context release reads the verify endpoint's identifier as `owid` only, so a call naming `51did` alone answers 400 "Can not verify owid". `DidClient::verify` now sends the identifier under both names, `51did` and `owid`, in the query string, and the endpoint reads one and ignores the other. Redeem is unchanged, `51did` only in the form body. Files changed: fodid/src/client.rs and fodid/tests/client_tests.rs, whose two verify URL assertions now expect both names. Tests: `cargo test -p fodid --all-features` is 69 passed and 3 ignored as before. clippy with every feature and `cargo fmt --check` are clean.
Describe the verify parameter aliases and key start fields as stable compatibility behavior.
jwrosewell
force-pushed
the
feature/51did-creator-context-example
branch
from
August 28, 2026 15:15
5538849 to
cf8cc7d
Compare
Use the canonical page asset byte for byte and serve the shared Rust stylesheet at the relative URL it references.
Return the required key fetch error instead of checking a signature against a schedule that may not contain the correct key.
The last two commits on this branch gave the reader a published maximum envelope size and a private payload maximum, and made every constructor and the cloud client enforce them. Both were wrong. The creator domain is a deployment parameter, so a self-hosted container may sign with a longer domain and the identifiers it issues must still parse, and the service deliberately accepts a creator context section of a version it does not implement at any length, so an older verifier keeps working when a newer version ships. A reader that refuses either breaks on a deployment nobody told it about. Removed are the public maximum constant and its re-export, the private payload and base64 maximums derived from it, the size checks in every constructor, the helpers behind them, the two error variants nothing can raise any more, the README paragraph stating the figure and the tests asserting it. The reader is back to its lower bound alone, where a payload must be at least the base length for its type and anything longer is accepted and left to the cloud to judge. What the change was right about was refusing obviously hostile input before doing any work, so the client keeps a guard, bounded generously enough to carry no information about the layout, named and documented for what it is rather than as the size of a 51Did. The guard sits at the request boundary, so a third party implementation of DidInput passes through it too. Fixed at the same time, an identifier arriving with a newline or a stray space around it was measured and had its alphabet converted before the whitespace was stripped, so a perfectly valid value was refused for its size. The reader and the client now strip first and work the base64 padding out from the stripped length, with tests covering a leading space, a trailing space and a trailing newline in both alphabets. Two client tests had been rewritten until they only asserted that a constructor failed, which tested nothing about the client because no client method ran. They are replaced by tests that call verify and redeem with an over length string and assert that no request was sent and no key was fetched, and by tests showing that a long creator domain and a long context section both verify. The clippy warnings the removed code carried have gone with it, so cargo clippy --all-targets --all-features and cargo clippy -p fodid --all-targets are both clean, as is cargo fmt --all --check. The crate version is unchanged, which is a separate release decision. The fodid crate passes 77 tests with 3 ignored, before and after. The examples workspace passes 89 tests with 11 ignored, before and after.
The boundary tolerance is internal, so nothing in the package should let a reader work out what it is. The constant is already private and its doc comment carries no figure, but the tests used offsets sitting close either side of the real value, which narrowed it to a small range. The offsets are now far apart, being one minute on the inside and a full hour on the outside, so the tests still prove the neighbouring key is tried just inside a boundary and not tried well outside it while saying almost nothing about where the line sits. Comments were reworded to match. Behaviour and the constant itself are unchanged. Checked by setting the tolerance to zero, where both inside assertions fail, and to two hours, where both outside assertions fail, so neither half passes trivially.
The crate level documentation linked to the client module and to DidClient, both of which exist only when the cloud feature is on, so rustdoc could not resolve either link in the default configuration. CI runs cargo doc --workspace --no-deps with no features, so every platform failed there while the same build with all features passed. The two links are now plain code spans naming the same items, so the paragraph reads the same and resolves in both configurations. Verified with the workflow's own commands rather than a convenient subset: cargo doc --workspace --no-deps and the same with all features for fodid, cargo fmt --all --check, cargo clippy --workspace --all-targets and for fodid with all features, and cargo test -p fodid --all-features at 77 passing.
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The 51Did creator context feature for Rust, in two parts.
1. The creator context web example
Adds the web example under
examples/fodid-examples, beside the existing example crates. Every 51Did the 51Degrees cloud issues carries a creator context, which binds the identifier to the browser and connection it was created on. The example serves a page that creates a 51Did from the browser, verifies it from the browser withverify-full(which answers only with a sealed result the browser cannot read or forge), and hands the sealed result to the example's own server, whose/redeemroute redeems it with the licence key that only the server holds. Opening the page's copy link in a different browser shows the creator context failing to validate while the signature still verifies, which is the demonstration.The same commit lets every cloud example in this repository be pointed at another host through the
51DEGREES_CLOUD_ENDPOINTenvironment variable the READMEs describe.2. The cloud client in the
fodidcrate, used by the exampleThe
fodidcrate gains a cloud client behind an opt-incloudfeature, in a newclientmodule, so a server that checks a 51Did never writes HTTP or key handling of its own. The feature is opt-in so the reader alone pulls in no HTTP stack and the crate keeps building for WebAssembly consumers.New surface in
fodid:FodId::from_base64now accepts both base64 alphabets, the standard one with padding as the cloud issues it and the URL-safe one (-and_, padding optional) a page uses when it puts the identifier in a link. Both decode to the same envelope.FodId::as_base64_url(), the URL-safe form without padding, for putting an identifier in a URL.FodId::date_minutes(), the envelope date as the count of minutes since 2020-01-01T00:00:00Z, which is how the wire format stores it.FodId::license_id()is documented as the field's raw value, because on an identifier carrying a creator context the four bytes hold an encrypted value that only 51Degrees can turn back into a licence identifier.New surface in
fodid::client(with thecloudfeature):DidClient, built withDidClient::builder(resource_key)plus an optionallicence_key,endpoint(defaulting to the public cloud or51DEGREES_CLOUD_ENDPOINT, with a trailing slash normalised) andtransport.public_keys()fetches the signing public keys once and caches them for a day, andpublic_key_for(&fod_id)picks the key in force when the identifier was created, refetching once when the held list does not cover the date. A required refresh failure is returned because the held schedule may not contain the correct key.verify_signature(&fod_id)checks the signature offline against that key (and a neighbouring key within a small tolerance of a boundary, never every earlier key), withverify_signature_detailedtelling an invalid signature from a date no held key covers.verify(&fod_id_or_string)checks through the cloud's verify endpoint. The identifier is sent under both parameter names,51didandowid, so the request works with hosts that read either parameter. Hosts that recognise both prefer51didand keepowidas a compatibility alias.redeem(&fod_id_or_string, result, challenge)posts the sealed result with the licence key and returns a typedRedeemResultcarryingcontext(Verified,Mismatch,NoContext,NotCheckable,Expired,Replayed,Unreadable,Unconfirmed),signature,factorswhen the cloud sent them,verified_at,seconds_since_verified, the HTTP status and the raw body. A 400 raisesClientError::InvalidIdentifier, a 404 raisesClientError::NotSupported, any other statusClientError::Httpand no answer at allClientError::Transport.Transport, the HTTP trait the client sends through, withUreqTransportas the default and a way for tests to inject their own.Credentials never reach a query string. The resource key travels in the route of the key and verify calls and in the form body of the redeem POST, and the licence key only in that form body. Every request sends a
User-Agentnaming the crate and its version.The example's
/redeemroute (theredeem_withfunction inexamples/fodid-examples/src/bin/fodid-web-creator-context.rs) now builds oneDidClientat start-up, parses the incoming 51Did withFodId::from_base64, checks its signature offline, redeems through the client, and answers the page with the cloud's status and a JSON body in the cloud's own shape plus one extra field,serverSignature. The page is byte-identical to the page in the other client examples. Its relative stylesheet URL is served from the shared Rust asset. The example depends on thefodidcrate from this repository with thecloudfeature, so this pull request tests the branch, and itsreqwestdependency goes because the client replaces it.The
fodidREADME gains a "Verifying on your server" section covering parse, offline verify, cloud verify and redeem in that order, and says thatverify-contextandverify-fullare browser calls because the context describes the browser's own connection. The example README's "copy these lines into your server" section shows the client calls.The pull request workflow now also tests and lints the
fodidcrate with every feature on, because the workspace commands leave the opt-in feature off, and runs the client's live tests alongside the existing ones when a resource key is available.Tests
cargo test -p fodid --all-features: 24 passed and 1 ignored before, 77 passed and 3 ignored after (5 unit, 43 client, 28 reader, 1 doctest, with the 3 ignored being the live cloud tests that need a resource key).cargo test --config source.toml -p fodid-examplesinexamples/): 4 passed before, 8 passed after, including one that drives/redeemwith a fake cloud and checks the answer's shape and theserverSignaturefield.cargo test --config source.tomlinexamples/): 85 passed and 11 ignored before, 89 passed and 11 ignored after.cargo clippy --all-targets --all-features -- -D warnings,cargo clippy -p fodid --all-targets -- -D warnings,cargo docandcargo fmt --all --checkare clean.The unit tests use an injected transport and a real key pair, so no network is used. The live tests are
#[ignore]d, as the existing live test is, and skip in a plain run.What the reader accepts, and the client's guard
The reader has no upper bound. A payload must be at least the base length for its identifier type, and anything beyond that base is a creator context section whose exact lengths belong to the cloud, so a longer payload is accepted and left to the cloud to judge. The creator domain is a deployment parameter as well, so a self-hosted container may sign with a longer domain and the identifiers it issues still parse. Both matter because the service deliberately accepts a context section of a version it does not implement, which is what keeps an older verifier working when a newer version ships.
DidClientkeeps one guard against obviously malformed input, refusing an encoded value far longer than any identifier before it decodes anything, fetches a key or calls the cloud. The bound is arbitrary and generous on purpose and says nothing about how long a 51Did is. It sits at the request boundary, so a third party implementation ofDidInputpasses through it too.Both the reader and the client strip leading and trailing whitespace from an encoded identifier before measuring it or converting its alphabet, and work the base64 padding out from the stripped length, so a value carrying a newline or a stray space from a link, a header or a file reads back to the same envelope as the clean form.
Public method signatures are unchanged, and general cloud response bodies are not capped.