Add the 51Did creator context web example - #64
Merged
Conversation
Adds the web example for the 51Did creator context feature under fiftyone_pipeline_did/examples/creator_context_web, 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.
Adds DidClient to fiftyone_pipeline_did so a server never hand-writes cloud URLs or key handling for a 51Did. The client fetches the signing public keys once and caches them for a day, picks the key in force when an identifier was created (the entry whose start is latest on or before the identifier's date, plus the neighbouring key within fifteen minutes of a boundary, never an earlier key), verifies a signature offline against it, verifies a signature through the cloud's verify endpoint, and redeems a sealed creator context result with the licence key, returning a typed RedeemResult. The resource key, the 51Did, the sealed result, the challenge and the licence key all travel in the body of the redeem POST, so no credential reaches an access log. A 404 from redeem raises DidNotSupportedError, a 400 raises DidArgumentError (a ValueError) with the cloud's message, and every other unexpected status raises DidClientError with the status and body. The client uses urllib and json from the standard library, so the package gains no dependency. FodId.from_base64 now accepts the URL-safe base64 alphabet as well as the standard one, with or without padding, which is the form a page puts in a link. FodId gains as_base64_url, date_minutes and the static helpers to_standard_base64 and to_base64_url, and license_id documents that on an identifier carrying a creator context the field holds an encrypted value that identifies nothing outside 51Degrees. The reader's minimum length rule is unchanged. The creator context example's /redeem route now builds one DidClient at start-up, parses the incoming 51Did, checks its signature offline, then redeems, and answers the page in the cloud's own shape plus a serverSignature field. page.html is unchanged. The example reaches the package by path when it is not installed, so the branch is what runs. Files changed: fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py (new) fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py fiftyone_pipeline_did/setup.py fiftyone_pipeline_did/readme.md fiftyone_pipeline_did/examples/creator_context_web/server.py fiftyone_pipeline_did/tests/envelope.py (new, shared builders) fiftyone_pipeline_did/tests/test_did_client.py (new) fiftyone_pipeline_did/tests/test_did_client_live.py (new) fiftyone_pipeline_did/tests/test_creator_context_server.py (new) Tests (python -m tox -e py in fiftyone_pipeline_did, as CI runs them, and pytest directly): before, 33 passed. After, 100 passed and 2 skipped, the skipped pair being the live tests that run only with resource_key (or _51DEGREES_RESOURCE_KEY) set.
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
31acb5c to
90d6fb6
Compare
The last two commits on this branch put a maximum size for a 51Did in the package, as a public constant on FodId with a private payload maximum behind it, enforced on every construction path and again in the client. That was wrong for two reasons. 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. The service also accepts a creator context section of a version it does not implement at any length, so an older reader keeps working when a newer version ships, and this package must behave the same way. Removed the public constant, the private payload maximum, the base64 character count derived from it, every check that enforced them, the error messages that quoted them and the tests that asserted them, and the readme paragraph that stated the maximum has gone with them. The reader is back to its lower bound, where a payload must be at least the base length for its identifier type and anything longer is a creator context section left to the cloud to judge, and the docstring saying so is restored. What replaced it is a generous guard in the client alone. An encoded identifier far longer than anything the cloud issues is refused before it is decoded, before a key is fetched and before the cloud is called, under a constant named for what it is. The figure is arbitrary on purpose so that nothing about the layout of an identifier can be read from it. Surrounding whitespace is stripped before the guard measures the text, as the reader already strips it before decoding, so the two now agree on a value that arrives with a newline or a space around it. Three tests had been rewritten to assert that the constructor throws and then that no request was made, which proved nothing because no client method ran. Each client entry point carrying the guard now has a test that calls it with text far too long and checks that no key was fetched and no request was made. New tests cover a payload far longer than the base, a long creator domain, a long envelope in the reader, and an identifier with surrounding whitespace parsing to the same value as the clean form. The key boundary tolerance is marked internal in the cloud, so the figure comes out of view here as well. The readme and the offline check now say a short tolerance either side of a boundary, and the constant is private to the client. The behaviour is unchanged. The suite gives 109 passed and 2 skipped both before and after, on Python 3.9 with cryptography 41.0.7 and on Python 3.14.
The key boundary tolerance is internal, so the tests should not narrow it down. The dates they used sat either side of the real figure and between them gave it away to within a few minutes. They now sit a long way apart, one minute inside the allowance and one hour outside it, which still proves the neighbouring key is tried just inside a boundary and is not tried well outside one, whilst saying almost nothing about where the line falls. No production code or behaviour changes.
The boundary tolerance value stays exactly as it is because a constant cannot be hidden in a public repository, but the writing around the constant is what turned a duration into a described security parameter, so the writing goes. The block comment on _BOUNDARY_TOLERANCE is gone entirely, as the name already says what the figure is. The verify_signature docstring, the _candidates_for_date docstring and the readme keep a plain statement of what happens, being that the neighbouring key is tried near a period boundary and no earlier key is ever tried, with the sentences that explained why the tolerance exists and what it defends against removed. No behaviour, no value and no test changed. The suite runs as tox.ini runs it, with 109 passed and 2 skipped at 97 per cent coverage, and the package still byte-compiles on Python 3.9.
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 in the
fiftyone_pipeline_didpackage.The creator context web example
Adds the web example under
fiftyone_pipeline_did/examples/creator_context_web(server.py,page.htmland the vendored design system stylesheet), beside the existing example there. The page creates a 51Did from the browser, verifies it withverify-fullso the cloud observes the browser's live connection, and hands the encrypted result to its own server, which redeems it with the licence key. Opening the page's copy link in a different browser shows the creator context refusing a transplanted identifier. The branch also lets every cloud example in this repository be pointed at another host through theFOD_CLOUD_API_URLenvironment variable the READMEs describe.The 51Did cloud client
New in the package
DidClient, constructed withresource_key(required),licence_key(optional, server side only),endpoint(optional, defaults toFOD_CLOUD_API_URLthen the public cloud, trailing slash normalised),transport(optional, a callable taking the preparedurllib.request.Requestand returning(status, body), or anurllib.request.OpenerDirector, defaulting tourllib.request.urlopen) andnow(optional clock for tests). It usesurllibandjsonfrom the standard library, so the package gains no dependency. Every request sends aUser-Agentnaming the package and its version.public_keys()fetchesid/key/{resource}once, readsstartsAt(orcreatedwhere a host emits only that) andpublicKey, ignoresweekStart, and caches the list for a day behind a lock, so one instance serves a whole server across threads.public_key_for(fod_id)returns the key in force when the identifier was created, being the entry whose start is latest on or before the identifier's date, fetching again once when no entry covers the date, the date is later than the newest start held, or the list is a day old.verify_signature(fod_id)andverify_signature_detailed(fod_id)verify offline: envelope version 3, payload at least the base length for its type (a longer payload carries a creator context and is accepted), and the signature checked against the key in force plus the neighbouring key within a short tolerance either side of a period boundary. No earlier key is ever tried. The detailed form answers aSignatureCheckwhose reason is one ofSignatureReason.VERIFIED,VERSION,LENGTH,NO_KEYorSIGNATURE.verify(fod_id_or_string)callsid/verify/{resource}with the identifier under both the51didandowidquery names, so the call works with hosts that read either parameter. Hosts that recognise both prefer51didand keepowidas a compatibility alias. It returns a boolean and raisesDidArgumentError(aValueError) with the cloud's message when the value is not a 51Did.redeem(fod_id_or_string, result, challenge)POSTsresource,51did,result,challengeandlicensein the form body toid/redeem, so no credential appears in a URL, and returns a typedRedeemResultwithcontext(ContextResult:VERIFIED,MISMATCH,NO_CONTEXT,NOT_CHECKABLE,EXPIRED,REPLAYED,UNREADABLE,UNCONFIRMED, with any unknown string mapped toUNREADABLEand kept incontext_raw),signature(SignatureResult:VERIFIED,INVALID,UNKNOWN),factors(only when the cloud sent them, eachFactorResult.VERIFIED,MISMATCHorNonewhere nothing was compared),verified_at,seconds_since_verified,status_codeandraw. A 200 and a 503 both produce a result. A 400 raisesDidArgumentError, a 404 raisesDidNotSupportedError(the host does not offer the creator context), and any other status raisesDidClientErrorcarryingstatus_codeandbody. A transport failure raises theOSErrorthe transport raised (urllib.error.URLErrorby default).to_dict()gives the cloud's own response shape for relaying to a page.FodId.from_base64now accepts the URL-safe base64 alphabet as well as the standard one, with or without padding, which is what a page puts in a link.FodIdgainsas_base64_url(),date_minutes(the envelope date as the unsigned count of minutes since 2020-01-01T00:00:00Z, with the epoch exported asDATE_EPOCH) and the static helpersto_standard_base64andto_base64_url. Thelicense_iddocumentation now says that on an identifier carrying a creator context the field holds an encrypted value that identifies nothing outside 51Degrees. The reader's minimum length rule is unchanged.The example
The
/redeemroute inexamples/creator_context_web/server.pynow builds oneDidClientat start-up from the same environment variables, parses the incoming 51Did withFodId.from_base64, checks its signature offline withverify_signature, then callsredeem, and answers the page with the cloud's status and a body in the cloud's own shape plusserverSignature(verifiedorinvalid).page.htmlis unchanged. A host without the creator context still answers 404 with a text body and an unreachable cloud answers 502 with{ "error": ... }. The handler class carries the client as a class attribute, so a test can start the server with a stand-in transport, and the example imports the package fromsrcbeside it when the package is not installed, so the branch is what runs.Documentation
The package readme gains a "Verifying on your server" section covering parse, offline verify, cloud verify and redeem in that order, notes that verify-context and verify-full are browser calls because the creator context describes the browser's own connection, and its "copy these lines into your server" block now shows the client calls. The
setup.pydescription names the client.Tests
Run with
python -m tox -e pyinfiftyone_pipeline_did, as CI does, orpytestdirectly.The two skipped tests are the live tests in
tests/test_did_client_live.py, which run only whenresource_key(the variable the repository's other live tests use) or_51DEGREES_RESOURCE_KEYis set and are skipped cleanly otherwise. They were not run here, because no resource key was set in this environment.The unit tests use an injected transport and touch no network. They cover both base64 alphabets and the round trip, including a value that arrives with a newline or a space around it,
date_minutesagainst the bytes on the wire, the key list (startsAt, thecreatedfallback, cache hit, each refetch rule with an injected clock, no refetch otherwise), key selection at and around a boundary and before the schedule, offline verification with real ECDSA P-256 key pairs (right key, wrong key, version 2, a payload shorter than the base, a payload longer than the base, a payload far longer than the base, a long creator domain, and text far too long to be an identifier), the cloud verify endpoint (valid, invalid, errors, both query names, the padded and unpadded forms, and text far too long to be an identifier), every redeem body (with factors, without, expired, replayed, unreadable, 503 unconfirmed, 400 errors, 404, an unknown context string) including that the request is a POST whose form carries the five fields and whose URL carries no key, and the example's/redeemroute driven over a socket (the cloud shape withserverSignatureadded, a forged envelope named invalid, a 400 for an unparseable identifier, the 404 text answer, a relayed 503 and 400, and the 502 for an unreachable cloud).The suite was run on Python 3.14 and on Python 3.9, the package's floor, where it gives the same result once
cryptography41.0.7 is installed. A newercryptographywheel fails to load its native module on Python 3.9 on the build machine, which is an environment fault rather than one of this change. Every file on the branch byte-compiles under Python 3.9.Size rules left to the cloud
Two earlier commits on this branch added a maximum size for a 51Did to the package and enforced it on every construction path. That was wrong on two counts. 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. The service also accepts a creator context section of a version it does not implement at any length, so an older reader keeps working when a newer version ships, and this package must behave the same way. The last commit removes the constant, the checks, the error messages, the tests and the readme paragraph that carried it, and puts the reader back to its lower bound, where a payload must be at least the base length for its identifier type and anything longer is a creator context section left to the cloud to judge.
What remains is a generous guard in the client alone. An encoded identifier far longer than anything the cloud issues is refused before it is decoded, before a key is fetched and before the cloud is called. The bound behind it is arbitrary on purpose, so nothing about the layout of an identifier can be read from it. Surrounding whitespace is stripped before the guard measures the text, as the reader already strips it before decoding, so the two agree on a value that arrives with a newline or a space around it.
Public method signatures are unchanged and no cloud response body is capped by anything here.