Skip to content

Add the 51Did creator context web example - #21

Open
jwrosewell wants to merge 12 commits into
mainfrom
feature/51did-creator-context-example
Open

Add the 51Did creator context web example#21
jwrosewell wants to merge 12 commits into
mainfrom
feature/51did-creator-context-example

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 with verify-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 /redeem route 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_ENDPOINT environment variable the READMEs describe.

2. The cloud client in the fodid crate, used by the example

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. 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_base64 now 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 the cloud feature):

  • DidClient, built with DidClient::builder(resource_key) plus an optional licence_key, endpoint (defaulting to the public cloud or 51DEGREES_CLOUD_ENDPOINT, with a trailing slash normalised) and transport.
  • public_keys() fetches the signing public keys once and caches them for a day, and public_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), with verify_signature_detailed telling 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, 51did and owid, so the request works with hosts that read either parameter. Hosts that recognise both prefer 51did and keep owid as a compatibility alias.
  • redeem(&fod_id_or_string, result, challenge) posts the sealed result with the licence key and returns a typed RedeemResult carrying context (Verified, Mismatch, NoContext, NotCheckable, Expired, Replayed, Unreadable, Unconfirmed), signature, factors when the cloud sent them, verified_at, seconds_since_verified, the HTTP status and the raw body. A 400 raises ClientError::InvalidIdentifier, a 404 raises ClientError::NotSupported, any other status ClientError::Http and no answer at all ClientError::Transport.
  • Transport, the HTTP trait the client sends through, with UreqTransport as 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-Agent naming the crate and its version.

The example's /redeem route (the redeem_with function in examples/fodid-examples/src/bin/fodid-web-creator-context.rs) now builds one DidClient at start-up, parses the incoming 51Did with FodId::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 the fodid crate from this repository with the cloud feature, so this pull request tests the branch, and its reqwest dependency goes because the client replaces it.

The fodid README gains a "Verifying on your server" section covering parse, offline verify, cloud verify and redeem in that order, and says that verify-context and verify-full are 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 fodid crate 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).
  • The example crate (cargo test --config source.toml -p fodid-examples in examples/): 4 passed before, 8 passed after, including one that drives /redeem with a fake cloud and checks the answer's shape and the serverSignature field.
  • The full examples workspace (cargo test --config source.toml in examples/): 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 doc and cargo fmt --all --check are 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.

DidClient keeps 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 of DidInput passes 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.

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
jwrosewell force-pushed the feature/51did-creator-context-example branch from 5538849 to cf8cc7d Compare August 28, 2026 15:15
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.
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