Skip to content

feat(auth0): support NEP-413 message signing - #104

Open
vriveraPeersyst wants to merge 6 commits into
mainfrom
feat/nep413-intent-signature
Open

feat(auth0): support NEP-413 message signing#104
vriveraPeersyst wants to merge 6 commits into
mainfrom
feat/nep413-intent-signature

Conversation

@vriveraPeersyst

@vriveraPeersyst vriveraPeersyst commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What

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 — 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:

  • Wallet-style sign-in. Signing a challenge to authenticate against a backend is the standard's primary use case, and what an app integrating fast-auth as its wallet needs first.
  • NEAR Intents with no gas and no account creation. A signed intent is published to the solver relay, which executes it and pays the gas itself. An implicit account is the hex of its public key and the verifier accepts its own key without registration, so the signer needs no on-chain account at all. xrp-mobile already works this way off NEAR.

No contract changes

The guard only verifies that fatxn equals 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 fa contract also already applies env::sha256 to sign_payload before the MPC call (fa/src/lib.rs:534 for 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:

  • Domain tag — the NEP-413 prefix (2³¹+413) must be present, so the bytes can never double as a NEAR transaction. Verified, never trusted from the caller.
  • Non-empty message — a payload the approval screen cannot display is a payload we refuse to sign.

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 transaction and delegateAction could arrive together and precedence decided silently, which would let a caller display one payload while a different one landed in fatxn. 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

NEP-413 Status
message, recipient, nonce, callbackUrl, state
Borsh payload + domain tag ✅ verified byte-for-byte against the NEP
Signature over sha256(prefix + payload) ✅ already done by the fa contract
Output {accountId, publicKey, signature, state} buildNep413SignedMessage()
Full-access keys only ✅ an implicit account's MPC key is its account key
Web callback via URL fragment not implemented — see below

The one gap: the NEP describes a web wallet returning its result by redirecting to callbackUrl with #accountId=…&signature=…&state=. fast-auth does not — the callbackUrl is 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.

state is deliberately not forwarded to Auth0. auth0-spa-js builds the authorize params as Object.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 what buildNep413SignedMessage() is for.

Changes

Package Change
packages/auth0 nep413 payload handling, the approval form, message + intent renderers in the shared helpers, and a package README
packages/providers/javascript NEP-413 serializer, requestMessageSignature(), buildNep413SignedMessage()
packages/sdks/browser Exposed on the signer, plus getImplicitAccountId()
packages/shared/core requestMessageSignature added as optional on the provider interface, so react-native and any other provider are unaffected

getImplicitAccountId() 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.js said 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 from src/forms/nep413/nep413_form.json (generated by pnpm build). Steps are in the new README.

Worth considering separately: a dedicated nep413:sign scope. It would let a client be granted message signing without transaction signing, but it needs a new permission on the API, so this PR reuses transaction:sign and 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

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>
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fast-auth-landing Ready Ready Preview Aug 13, 2026 3:59pm
fast-auth-spa Ready Ready Preview Aug 13, 2026 3:59pm

Request Review

…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>
@vriveraPeersyst vriveraPeersyst changed the title feat(auth0): sign NEAR Intents via NEP-413 payloads feat(auth0): support NEP-413 message signing Aug 13, 2026
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>
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