feat(auth0): support NEP-413 message signing - #104
Open
vriveraPeersyst wants to merge 6 commits into
Open
Conversation
Adds a third signing payload type alongside `transaction` and `delegateAction`,
so a client can ask the MPC to sign a NEP-413 off-chain message.
Why: both existing payload types are NEAR transactions, and every NEAR
transaction needs an on-chain account that pays gas. Intents do not — a signed
intent is published to the solver relay, which executes it against the intents
contract and pays the gas itself. Supporting NEP-413 therefore lets an app
operate on NEAR Intents with no account creation and no gas for the user.
The guard contract is untouched: it only checks that `fatxn` equals the bytes
handed to the MPC and never inspects them, so the payload type is entirely an
Auth0-side concern.
Because the contract cannot vet the payload, the action does, and refuses to
render anything it cannot describe to the user:
- the NEP-413 domain tag (2^31 + 413) must be present, so the bytes can never
double as a NEAR transaction;
- the recipient must be the expected verifier, configurable per tenant via the
INTENTS_RECIPIENT secret (defaults to intents.near);
- the message must be JSON carrying a non-empty `intents` array.
Also rejects requests carrying more than one payload type. Previously a
transaction and a delegate action could arrive together and precedence decided
silently — which would let a caller display one payload while a different one
landed in `fatxn`.
Replay protection needs nothing here: the verifier tracks spent nonces on-chain.
Changes:
- packages/auth0: `intent` payload handling, the Intent approval form, and
intent renderers in the shared form helpers
- packages/providers/javascript: NEP-413 serializer and
`requestIntentSignature()`
- packages/sdks/browser: expose it on the signer
- packages/shared/core: optional `requestIntentSignature` on the provider
interface, so providers that have not implemented it are unaffected
Deploying needs a new INTENT_FORM secret pointing at the imported form, and
optionally INTENTS_RECIPIENT on non-mainnet tenants.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ntents
The first pass was written from the NEAR Intents use case and anchored two things
it had no business anchoring: `recipient` was pinned to intents.near, and the
message had to carry a non-empty `intents` array. Between them, what shipped was
not "sign NEP-413" but "sign NEAR Intents".
NEP-413 is broader than that. The message is an arbitrary human-readable string —
a wallet sign-in challenge ("Sign in to example.com") is as valid as an intents
body — and `recipient` names whatever application the message is addressed to.
The standard's protection is that the user *sees* the recipient, not that the
wallet restricts it, which is how NEAR wallets behave.
What changed:
- recipient: any account by default; NEP413_ALLOWED_RECIPIENTS opts a tenant
into a comma-separated allowlist. A list of only separators is treated as
unset rather than as an allowlist that rejects everything.
- message: any non-empty string. JSON is parsed opportunistically so a NEAR
Intents body still gets the per-intent breakdown, and anything else is shown
verbatim — which is what the standard expects of a signed string.
- callbackUrl is surfaced on the approval screen; it is part of the signed
payload and the user should see where the result goes.
- state is accepted and echoed back, per the NEP. It is deliberately not part
of the signed bytes: the Payload struct does not include it.
- buildNep413SignedMessage() assembles the {accountId, publicKey, signature,
state} response shape the standard defines.
The domain tag check is untouched and non-negotiable — it is what keeps the bytes
from doubling as a NEAR transaction, and it is unrelated to the recipient.
Renamed throughout to match the scope: `intent` -> `nep413` (query key, form,
secrets) and requestIntentSignature -> requestMessageSignature.
Verified while writing this: the fa contract already applies env::sha256 to
sign_payload before handing it to the MPC (fa/src/lib.rs:534 for eddsa), which is
exactly what NEP-413 requires — sign the SHA-256 of the prefix plus serialized
payload. No contract change needed for that either.
227 tests passing, covering plain-text messages, non-intents JSON, arbitrary
recipients, the allowlist, and the fallback that shows the raw message whenever
the structured view is unavailable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was the last remnant of anchoring the payload to NEAR Intents — the first pass pinned `recipient` to intents.near, the second softened that to an opt-in allowlist, and neither was justified. The allowlist protects nothing. The attack NEP-413's recipient guards against is relaying: an app has you sign a message addressed to bank.near, then replays it there as you. The standard's defence is that you *see* bank.near on the approval screen while sitting in a different app — a tenant-level allowlist adds nothing to that, and the screen already renders the recipient unconditionally. It is also the wrong granularity. A recipient belongs to an application, not to a tenant serving many of them: a shared list is either permissive enough to filter nothing or breaks apps as they are added. Were per-app restriction ever wanted, client_metadata is where it belongs, not a global secret. What it did add was a secret to configure and a failure mode where a misspelled entry rejects every signature. No NEAR wallet restricts recipients for the same reasons. Removes parseRecipientAllowlist, the NEP413_ALLOWED_RECIPIENTS secret and their tests; keeps the recipient rendered on the approval screen, which is the actual protection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps found while checking whether the NEP-413 support is actually usable end to end. The action's deployment was undocumented. It expects four secrets and renders forms by id, but nothing in the repo said so — build.js mentions the JSON is "ready to upload" and stops there, leaving the import steps and the secret names in the head of whoever administers the tenant. The README now covers the payload types, the secrets table, how to import a form and wire its id, and how to iterate locally against the playground. The SDK also had no way to derive a NEAR implicit account id. That is the piece the gasless path needs: an implicit account is the hex of its ed25519 public key, needs no on-chain creation, and the intents verifier accepts its own key without registration — so an app can operate without provisioning an account per user. Deriving it by hand from the raw key bytes is easy to get subtly wrong, hence getImplicitAccountId() on the signer. Also covers requestMessageSignature's delegation and its "provider does not implement this" path, which had tests in the provider package but none at the signer boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…request
requestMessageSignature accepted a `state` and forwarded it in
authorizationParams, which does nothing: auth0-spa-js builds the authorize
params as Object.assign(..., authorizationParams, {..., state: <its own>}), so
its OAuth state always wins and the caller's value is dropped without a trace.
An option that silently does nothing is worse than no option.
It should never have travelled anyway. NEP-413 defines `state` as a value the
caller generates, holds, and matches when the result comes back — it is not part
of the signed payload and has no business reaching the authorization server,
which mints its own for the OAuth exchange.
Removed from the request options; buildNep413SignedMessage still takes it, which
is where the standard actually wants it. Documented on the response type, and
pinned with a test asserting no state reaches authorizationParams.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were exported for testing alongside stringifyActions, which decoding.spec already covers, but neither had a test of its own. stringifyIntents in particular serialises bigints — JSON.stringify throws on those by default, and token amounts are a plausible place for one to show up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
What
Adds a third signing payload type alongside
transactionanddelegateAction, so a client can ask the MPC to sign a NEP-413 off-chain message — any message, for any recipient.Why
Both existing payload types are NEAR transactions, and every NEAR transaction needs an on-chain account that pays gas. NEP-413 messages are not transactions: nothing is broadcast and no gas is spent. That unlocks two things fast-auth cannot do today:
xrp-mobilealready works this way off NEAR.No contract changes
The guard only verifies that
fatxnequals the bytes handed to the MPC — it never deserializes them (auth0-guard/src/lib.rs:209-227). The restriction to transactions was never on-chain; it lived entirely in the action.The
facontract also already appliesenv::sha256tosign_payloadbefore the MPC call (fa/src/lib.rs:534for eddsa), which is exactly what NEP-413 mandates — sign the SHA-256 of the prefix plus serialized payload. No redeploy, no re-audit.What the action enforces
The contract cannot vet the payload, so the action does, and refuses to render anything it cannot show the user. Two checks carry that weight, and only two:
The recipient is not restricted. Under NEP-413 it names the application a message is addressed to, and the protection is that the user sees it — which is why the screen always renders it, and why no NEAR wallet restricts it either. Restricting it in the action would also be the wrong granularity: a recipient belongs to an application, not to a tenant hosting many of them.
Also: one payload at a time
Previously
transactionanddelegateActioncould arrive together and precedence decided silently, which would let a caller display one payload while a different one landed infatxn. More than one is now refused outright.The approval screen
Two presentations, one payload. When the message carries NEAR Intents the intents are broken out one by one — receiver, token, amount — with unrecognized intent kinds flagged with a warning rather than rendered as if understood. Anything else, a sign-in challenge or arbitrary JSON, is shown verbatim, which is what the standard expects of a signed string. Recipient and callback URL are always on screen.
Coverage of the standard
message,recipient,nonce,callbackUrl,statesha256(prefix + payload)facontract{accountId, publicKey, signature, state}buildNep413SignedMessage()The one gap: the NEP describes a web wallet returning its result by redirecting to
callbackUrlwith#accountId=…&signature=…&state=. fast-auth does not — thecallbackUrlis signed and displayed, but the result comes back through the Auth0 flow and the signature is collected afterwards via the relayer. It is not a missing parameter but a different flow: when Auth0 finishes, the signature does not exist yet.That matters for registering fast-auth as a wallet-selector wallet. It does not matter for an app using fast-auth as its own wallet, which covers the cases driving this PR. Tracked separately in #105.
stateis deliberately not forwarded to Auth0. auth0-spa-js builds the authorize params asObject.assign(…, authorizationParams, {…, state: <its own>}), so its OAuth state always wins and a caller value would be dropped without a trace. Per the NEP the caller generates it, holds it, and attaches it to the result — which is whatbuildNep413SignedMessage()is for.Changes
packages/auth0nep413payload handling, the approval form, message + intent renderers in the shared helpers, and a package READMEpackages/providers/javascriptrequestMessageSignature(),buildNep413SignedMessage()packages/sdks/browsergetImplicitAccountId()packages/shared/corerequestMessageSignatureadded as optional on the provider interface, soreact-nativeand any other provider are unaffectedgetImplicitAccountId()is the piece the gasless path needs: an implicit account id is the hex of the ed25519 public key, and deriving it by hand from raw key bytes is easy to get subtly wrong.The package README is new. The action expects four secrets and renders forms by id, and none of that was written down anywhere —
build.jssaid the JSON was "ready to upload" and stopped there. It now covers the payload types, the secrets, importing a form and wiring its id, and iterating locally against the playground.Tests
229 passing across the three packages, 76 of them new.
The decoding specs cover each rejection path as its own case — wrong tag, delegate-action prefix reused as a tag, real transaction bytes submitted as a message, empty message, truncated payload — plus the cases an intents-only reading would wrongly reject: plain-text challenges, JSON that is not an intents body, arbitrary recipients. Fixtures encode with a borsh schema transcribed from the NEP rather than imported from the action, so editing the action's schema breaks the round-trip instead of silently agreeing with itself.
The form specs assert that a plain message is shown verbatim, that intents get their per-intent breakdown, and that an unreadable structured view falls back to the raw message rather than hiding what is being signed.
Deploying
One new secret:
NEP413_FORM, the id of the form imported fromsrc/forms/nep413/nep413_form.json(generated bypnpm build). Steps are in the new README.Worth considering separately: a dedicated
nep413:signscope. It would let a client be granted message signing without transaction signing, but it needs a new permission on the API, so this PR reusestransaction:signand stays mergeable on its own.Not verified here
The end-to-end chain — Auth0 mints
fatxn→ guard verifies → MPC signs → a verifier accepts the signature — needs a tenant. Everything here is covered by unit tests and the local form playground; the full path should be exercised against staging before this ships.🤖 Generated with Claude Code