Skip to content

Add the 51Did creator context web example - #113

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

Add the 51Did creator context web example#113
Automation51D merged 10 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 two parts, being the web example that shows the flow and the cloud client in pipeline.did that the example's server now uses.

The creator context web example

Adds pipeline.developer-examples/pipeline.developer-examples.fodid's CreatorContextDemoServer, a small server on the JDK's own web server that serves page.html. The page creates a 51Did in the browser through the cloud json endpoint, verifies it from the browser with verify-full (which answers with a sealed result the browser cannot read or forge), and hands the sealed result to the server's /redeem route, which redeems it with the licence key the browser never sees. Once the identifier validates, the page offers a link carrying the same 51Did, and opening that link in another browser shows the signature still verifying while the creator context does not, which is the copied identifier caught at presentation. The same commit lets every example that calls the cloud be pointed at another host through FOD_CLOUD_API_URL, the variable the cloud request engine already honours, with the resource key from _51DEGREES_RESOURCE_KEY. The cloud engine example reads both (falling back to the star sign service it was written for) and reports plainly when the host offers no star sign product, and the root README describes the variables.

The cloud client, used by the example

pipeline.did gains DidClient, one type for everything a server does with a 51Did against the 51Degrees cloud, so server code never hand-writes HTTP or key handling. New public surface:

  • FodId.fromBase64 now accepts the URL-safe base64 alphabet (-, _, padding optional) as well as the standard one the cloud issues, normalising before the OWID library sees it, because that library's decoder ignores characters outside the standard alphabet rather than refusing them. Whitespace around the value is stripped at the same point, so an identifier read from a header, a file or a form field with a newline or a space around it reads as the same identifier as the clean form, and the base64 padding is worked out from the stripped length. FodId.asBase64Url() gives the URL-safe form without padding, and FodId.getDateMinutes() gives the envelope's own date field, the unsigned count of minutes since 2020-01-01T00:00:00Z. getLicenseId() is documented as the raw field, which on an identifier carrying a creator context holds an encrypted value only 51Degrees can read. The minimum length rule is unchanged.
  • DidClient(resourceKey), DidClient(resourceKey, licenceKey), DidClient(resourceKey, licenceKey, endpoint) and DidClient.builder(resourceKey) (which also takes an HttpTransport and a Clock). The endpoint defaults to https://cloud.51degrees.com/api/v4/, or FOD_CLOUD_API_URL where set, and a trailing slash is added where missing.
  • publicKeys() fetches GET id/key/{resource} once and holds the list, reading startsAt (or the compatibility field created when startsAt is absent) and publicKey. publicKeyFor(fodId) returns the key in force at the identifier's date, refetching once when the held list has no key for the date, when the date is later than the newest start held, or when the list is more than a day old. A required refresh failure propagates because the held schedule may not contain the correct key.
  • verifySignature(fodId) and verifySignatureDetailed(fodId) check the signature offline the way the cloud's verify endpoint does: version 3 only, the payload at least the base length for its type (a longer payload carries a creator context section and is accepted), and the key in force at the identifier's date plus the neighbouring key within the cloud's tolerance either side of a boundary, never every earlier key.
  • verify(fodId) calls GET id/verify/{resource}?51did= and returns the cloud's valid, raising IllegalArgumentException with the cloud's message for a value that is not a 51Did. 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(fodId, result, challenge) sends POST id/redeem (the bare path, because the POST route carries no resource segment) with resource, 51did, result, challenge and license in the form body (neither key is ever in a URL) and returns a typed RedeemResult for a 200 or 503 answer: Context (VERIFIED, MISMATCH, NO_CONTEXT, NOT_CHECKABLE, EXPIRED, REPLAYED, UNREADABLE, UNCONFIRMED, with any unknown word mapped to UNREADABLE and kept in getContextValue()), Signature (VERIFIED, INVALID, UNKNOWN), getFactors() when the cloud sent them, getVerifiedAt(), getSecondsSinceVerified(), getStatusCode() and getRaw(). A 400 raises IllegalArgumentException, a 404 raises DidNotSupportedException (the host does not offer the creator context), any other status raises DidHttpException with the status and body, and an unreachable host raises IOException.
  • HttpTransport, an interface over HttpURLConnection with Request and Response types, so tests inject the network. SigningKey is one entry of the key list.

Every request carries a User-Agent of pipeline.did/{version} from the jar manifest. The package adds org.json:json at the version pipeline.cloudrequestengine already uses, so the pipeline carries no new dependency. Everything compiles at the Java 8 level and Animal Sniffer checks the API.

The example's /redeem route (CreatorContextDemoServer.redeem) now builds one DidClient at start-up, parses the incoming 51Did with FodId.fromBase64, calls verifySignature, then redeem, and answers the page in the cloud's own shape (signature, context, factors when present, verifiedAt, secondsSinceVerified) with one field added, serverSignature. page.html is unchanged. A host without the creator context still answers 404 with a text body, which the page reports as not supported by this host, and an unreachable cloud answers 502 with { "error": ... }. The example readme's copy-these-lines section shows the client calls, and the package readme gains a "Verifying on your server" section covering parse, offline verify, cloud verify and redeem, with a note that verify-context and verify-full are browser calls because the context describes the browser's own connection.

Tests

Unit tests use the injected transport and a real ECDSA key pair, with no network. They cover both base64 alphabets and the round trip, getDateMinutes, the key list (startsAt, the created fallback, cache hit, each refetch rule and a failed required refresh with an injected clock), key selection at and around a boundary, offline verification (right key, wrong key, version 2, short payload, payload with a context section, a long context section signed with a longer creator domain, random identifier), cloud verification (200 valid, 400 invalid, 400 errors), and every redeem outcome including 503, 400, 404, an unknown context word, the form fields, and the licence key's absence from the URL. The example's route is tested for the cloud's shape with serverSignature, factors, 503, 404, 502 and 400. Two live tests run only when _51DEGREES_RESOURCE_KEY is set and are skipped otherwise. Further tests cover an identifier arriving with a leading space, a trailing space or a trailing newline in either alphabet, a long creator context section and a longer creator domain reading back through every factory, and the client turning away an over-long encoded value through both its string and its object entry points without making a request.

Module Before After
pipeline.did 32 run, 0 failed 99 run, 0 failed, 2 skipped (live tests without a resource key)
pipeline.developerexamples.fodid 1 run, 0 failed 9 run, 0 failed

Both modules build, with mvn -pl pipeline.did -am test and mvn -pl pipeline.developer-examples/pipeline.developer-examples.fodid -am package.

Identifier size is left to the cloud

The package states no maximum length for a 51Did, and the reader keeps only the lower bound it has always had, which is that a payload must be at least the base length for its identifier type. Anything longer is accepted and left to the cloud to judge. Two reasons make a fixed maximum in the package wrong. The creator domain is a deployment parameter, so a self-hosted container may sign with a longer domain than the cloud does and its identifiers must still parse. The service also accepts a creator context section of a version it does not implement at any length, which is what lets an older verifier keep working when a newer version ships, and the package has to behave the same way.

DidClient keeps one generous bound so it does not do work on input that cannot be an identifier at all. An encoded identifier longer than 4096 characters is refused before the client decodes it, fetches a key or calls the cloud. That figure is arbitrary and far above anything the cloud issues, and its only job is to turn away obviously malformed input. Public method signatures are unchanged, and cloud response bodies are not capped by any of this.

Adds the web example for the 51Did creator context feature under
pipeline.developer-examples/pipeline.developer-examples.fodid, 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 did package gains DidClient, one type for everything a server does
with a 51Did against the 51Degrees cloud, so that server code never
hand-writes HTTP or key handling. It fetches the published signing keys
once and holds them, picks the key in force when a 51Did was created
(with the cloud's tolerance either side of a key boundary), verifies a
signature offline against that key or through the cloud's verify
endpoint, and redeems a sealed creator context result with the licence
key into a typed RedeemResult. The resource key travels in the route
and the licence key only in the POST form body, never in a URL. An
HttpTransport interface over HttpURLConnection lets tests stand in for
the network. JSON is read with org.json at the version the pipeline's
cloud request engine already uses.

FodId now parses both base64 alphabets, so the URL-safe form a page
puts in a link is accepted, and adds asBase64Url() and getDateMinutes().
The License Id documentation says that on an identifier carrying a
creator context the field holds an encrypted value only 51Degrees can
read. The reader's minimum length rule is unchanged.

The creator context example's /redeem route uses the client instead of
building the cloud URL itself. It parses the incoming 51Did, checks the
signature offline, redeems with the licence key, and answers the page in
the cloud's own shape with one field added, serverSignature. The page
is unchanged. The example readme shows the client calls and the package
readme gains a "Verifying on your server" section.

Files changed
  pipeline.did/pom.xml, README.md
  pipeline.did/src/main/java/fiftyone/pipeline/did/
    DidClient.java, DidHttpException.java,
    DidNotSupportedException.java, FodId.java, HttpTransport.java,
    RedeemResult.java, SigningKey.java, package-info.java
  pipeline.did/src/test/java/fiftyone/pipeline/did/
    DidClientTests.java, DidClientLiveTests.java,
    FodIdTestFactory.java, FodIdTests.java
  pipeline.developer-examples/pipeline.developer-examples.fodid/
    pom.xml, README.md, CreatorContextDemoServer.java,
    ExampleTests.java

Tests
  pipeline.did: 92 run, 0 failed, 2 skipped (the live cloud tests,
  which run only with a resource key), up from 32.
  pipeline.developerexamples.fodid: 9 run, 0 failed, up from 1.
The redeem controller's POST route carries no resource segment, so a
POST to id/redeem/{resource} answers 404 and the resource key is read
from the form body instead. DidClient.redeem now posts to the bare
id/redeem path with resource, 51did, result, challenge and license all
in the form. The GET calls (id/key/{resource}, id/verify/{resource})
are unchanged. The tests that assert the redeem URL and form, and the
class documentation on where each credential travels, are updated to
match.

Tests
  pipeline.did: 92 run, 0 failed, 2 skipped (live tests without a
  resource key).
  pipeline.developerexamples.fodid: 9 run, 0 failed.
The verify endpoint reads the identifier as 51did and keeps owid as an
alias, but a cloud that has not taken the creator context release reads
owid only and answers 400 "Can not verify owid" to a request carrying
51did alone. DidClient.verify now sends the identifier under both names
in the query string, so it works against either. Redeem is unchanged,
because it exists only on the updated cloud. The test asserting the
verify URL is updated to match.

Tests
  pipeline.did: 92 run, 0 failed, 2 skipped (live tests without a
  resource key).
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 058f28d to 9f5b6f1 Compare August 28, 2026 15:14
Return the required key fetch error instead of checking a signature against a schedule that may not contain the correct key.
The package carried a public maximum envelope length, a private payload
maximum and a base64 character maximum worked out from it, and every
factory refused anything longer. All of that is removed, along with the
README paragraph that stated the maximum, the error messages that quoted
the figures and the tests that asserted them. The reader keeps the lower
bound it always had, which is that a payload must be at least the base
length for its identifier type, so anything longer is accepted and left
to the cloud to judge.

The maximum was wrong on two counts. The creator domain is a deployment
parameter, so a self-hosted container may sign with a longer domain and
its identifiers must still parse. The service also accepts a context
section of a version it does not implement at any length, so an older
verifier keeps working when a newer version ships, and the package has
to behave the same way.

What replaced it is one generous bound in the client, not in the reader.
An encoded identifier longer than 4096 characters is refused before the
client decodes it, fetches a key or calls the cloud. That figure is
arbitrary and far above anything the cloud issues, because its only job
is to turn away obviously malformed input.

Two defects in the same code are fixed. Leading and trailing whitespace
is now stripped from an encoded identifier before it is measured and
before the alphabet is converted, so a valid identifier with a trailing
newline, a leading space or a trailing space parses to the same value as
the clean form. The base64 padding is worked out from the stripped
length rather than the raw one, which was already wrong before the
maximum was added.

The dead length helpers in FodId and DidClient go too, because the
constructor already enforces the remaining rules and an OWID signature
is always read as a fixed 64 bytes, so nothing reachable through the
public API could make them fail. The README no longer contradicts
itself, as it said in one place that the context section may be any
length and in another gave a fixed envelope maximum.

Tests before: 101 run, 0 failures, 2 skipped.
Tests after: 99 run, 0 failures, 2 skipped.
The boundary tests used offsets of five and twenty minutes, which
narrowed the allowance to a small range for anyone reading them. The
offsets now sit a minute inside and an hour outside it, so each test
still proves which key is tried on its side of a boundary without
recording how wide the allowance is.

The two offsets are named JUST_INSIDE and WELL_OUTSIDE so the point of
each one is clear everywhere they are used.
@Automation51D
Automation51D merged commit ddb316c into main Aug 29, 2026
1 check passed
@Automation51D
Automation51D deleted the feature/51did-creator-context-example branch August 29, 2026 03:39
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