Skip to content

Add the 51Did creator context web example - #64

Merged
Automation51D merged 8 commits into
mainfrom
feature/51did-creator-context-example
Aug 29, 2026
Merged

Add the 51Did creator context web example#64
Automation51D merged 8 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 in the fiftyone_pipeline_did package.

The creator context web example

Adds the web example under fiftyone_pipeline_did/examples/creator_context_web (server.py, page.html and the vendored design system stylesheet), beside the existing example there. The page creates a 51Did from the browser, verifies it with verify-full so 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 the FOD_CLOUD_API_URL environment variable the READMEs describe.

The 51Did cloud client

New in the package

DidClient, constructed with resource_key (required), licence_key (optional, server side only), endpoint (optional, defaults to FOD_CLOUD_API_URL then the public cloud, trailing slash normalised), transport (optional, a callable taking the prepared urllib.request.Request and returning (status, body), or an urllib.request.OpenerDirector, defaulting to urllib.request.urlopen) and now (optional clock for tests). It uses urllib and json from the standard library, so the package gains no dependency. Every request sends a User-Agent naming the package and its version.

  • public_keys() fetches id/key/{resource} once, reads startsAt (or created where a host emits only that) and publicKey, ignores weekStart, 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) and verify_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 a SignatureCheck whose reason is one of SignatureReason.VERIFIED, VERSION, LENGTH, NO_KEY or SIGNATURE.
  • verify(fod_id_or_string) calls id/verify/{resource} with the identifier under both the 51did and owid query names, so the call works with hosts that read either parameter. Hosts that recognise both prefer 51did and keep owid as a compatibility alias. It returns a boolean and raises DidArgumentError (a ValueError) with the cloud's message when the value is not a 51Did.
  • redeem(fod_id_or_string, result, challenge) POSTs resource, 51did, result, challenge and license in the form body to id/redeem, so no credential appears in a URL, and returns a typed RedeemResult with context (ContextResult: VERIFIED, MISMATCH, NO_CONTEXT, NOT_CHECKABLE, EXPIRED, REPLAYED, UNREADABLE, UNCONFIRMED, with any unknown string mapped to UNREADABLE and kept in context_raw), signature (SignatureResult: VERIFIED, INVALID, UNKNOWN), factors (only when the cloud sent them, each FactorResult.VERIFIED, MISMATCH or None where nothing was compared), verified_at, seconds_since_verified, status_code and raw. A 200 and a 503 both produce a result. A 400 raises DidArgumentError, a 404 raises DidNotSupportedError (the host does not offer the creator context), and any other status raises DidClientError carrying status_code and body. A transport failure raises the OSError the transport raised (urllib.error.URLError by default). to_dict() gives the cloud's own response shape for relaying to a page.

FodId.from_base64 now 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. FodId gains as_base64_url(), date_minutes (the envelope date as the unsigned count of minutes since 2020-01-01T00:00:00Z, with the epoch exported as DATE_EPOCH) and the static helpers to_standard_base64 and to_base64_url. The license_id documentation 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 /redeem route in examples/creator_context_web/server.py now builds one DidClient at start-up from the same environment variables, parses the incoming 51Did with FodId.from_base64, checks its signature offline with verify_signature, then calls redeem, and answers the page with the cloud's status and a body in the cloud's own shape plus serverSignature (verified or invalid). page.html is 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 from src beside 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.py description names the client.

Tests

Run with python -m tox -e py in fiftyone_pipeline_did, as CI does, or pytest directly.

Tests
Before this branch 33 passed
On this branch now 109 passed, 2 skipped

The two skipped tests are the live tests in tests/test_did_client_live.py, which run only when resource_key (the variable the repository's other live tests use) or _51DEGREES_RESOURCE_KEY is 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_minutes against the bytes on the wire, the key list (startsAt, the created fallback, 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 /redeem route driven over a socket (the cloud shape with serverSignature added, 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 cryptography 41.0.7 is installed. A newer cryptography wheel 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.

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
jwrosewell force-pushed the feature/51did-creator-context-example branch from 31acb5c to 90d6fb6 Compare August 28, 2026 15:15
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.
@Automation51D
Automation51D merged commit 8fd4c69 into main Aug 29, 2026
1 check passed
@Automation51D
Automation51D deleted the feature/51did-creator-context-example branch August 29, 2026 01:23
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.

2 participants