Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions packages/auth0/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# @fast-auth/auth0

The Auth0 half of FastAuth: a PostLogin action that turns a signing request into a user
approval, and the forms that render it.

## How it fits together

FastAuth's guarantee is that the MPC only ever signs bytes Auth0 certified after showing them
to the user. The on-chain guard enforces one half of that — it checks that the bytes handed to
the MPC equal the `fatxn` claim in the access token, and nothing else. It never inspects them.

The other half lives here. The action is what decides *what* those bytes are allowed to be and
*how* they are described to the user before the token is minted.

```
/authorize?<payload> → action decodes + validates → form renders for approval
→ approved: fatxn = the exact bytes
→ denied: no token
```

Because the guard compares byte for byte, the action must place the payload in `fatxn`
unmodified. Any normalisation here would make every resulting signature fail on-chain.

## Payload types

The action accepts exactly one payload per request, on the `/authorize` query string:

| Query key | Contents | Form rendered |
| --------------- | ------------------------------------- | ---------------------- |
| `transaction` | Borsh-encoded NEAR `Transaction` | `TRANSACTION_FORM` |
| `delegateAction`| Borsh-encoded `DelegateAction` | `DELEGATE_ACTION_FORM` |
| `nep413` | Borsh-encoded NEP-413 message payload | `NEP413_FORM` |

Sending more than one is rejected: with several present, precedence would decide silently which
one lands in `fatxn`, and a caller could display one payload while a different one gets signed.

A payload that fails to decode is denied rather than rendered. If the screen cannot describe it,
the user cannot meaningfully approve it.

## Action secrets

Configured under **Actions → Library → your action → Secrets**. Despite the name, only the
audience is remotely sensitive — the form entries are identifiers Auth0 generates on import, and
they differ per tenant, which is why they are configuration rather than constants in the code.

| Secret | Value |
| ---------------------- | -------------------------------------------------------- |
| `ONCHAIN_AUDIENCE` | Identifier of the signing API. A request carrying a payload is only honoured for this audience, and a request *for* this audience without a payload is denied. |
| `TRANSACTION_FORM` | Form id for transaction approvals. |
| `DELEGATE_ACTION_FORM` | Form id for delegate action approvals. |
| `NEP413_FORM` | Form id for NEP-413 message approvals. |

## Deploying a form

Forms are built from source rather than edited in the dashboard: `build.js` inlines each
component's JS, CSS and schema into a template and emits a single importable file.

```bash
pnpm build # writes src/forms/<form>/<form>_form.json
```

Then, per form:

1. **Auth0 Dashboard → Forms → Create Form → Import from JSON**, and upload the generated file.
2. Copy the id Auth0 assigns to the created form.
3. Paste it into the matching action secret above.

Repeat per tenant — ids are not portable between staging and production.

Re-importing after a code change creates a *new* form with a new id, so remember to update the
secret, or the action will keep rendering the previous version.

## Local development

```bash
pnpm playground # serves the forms at http://localhost:5174
```

The playground renders the same components against mock payloads (`playground/payloads.js`)
with a shim standing in for the helpers that `build.js` inlines in production. It is the fastest
way to iterate on an approval screen without touching a tenant.

```bash
pnpm test # action decoding, handler dispatch, and form rendering
```

The fixtures encode payloads with NEAR's production encoders, and the NEP-413 fixtures use a
borsh schema transcribed from the NEP rather than imported from the action — so a change to the
action's schema breaks the round-trip instead of silently agreeing with itself.

## Adding a form

1. `src/forms/<name>/<name>_form_base.json` — the template. Custom components carry
`"$source": "<folder>"`, resolved against the form's own directory, or against the forms root
when the path contains a `/` (e.g. `shared/image`).
2. `src/forms/<name>/details/index.js` and `index.css` — the component. Rendering helpers live in
`shared/helpers` and are available as `__auth0FormHelpers`.
3. Register the form in the `FORMS` array in `build.js`.
4. Add the corresponding secret and render it from the action.
145 changes: 143 additions & 2 deletions packages/auth0/src/actions/authorize-app.action.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ const { deserialize } = require("borsh");

const TRANSACTION_KEY = "transaction";
const DELEGATE_ACTION_KEY = "delegateAction";
const NEP413_KEY = "nep413";

// NEP-413 CONSTANTS

/**
* Domain-separation tag mandated by NEP-413 (2^31 + 413). It is prepended to every signed
* off-chain message so the bytes can never be reinterpreted as a NEAR transaction, whose
* borsh encoding starts with the signerId length — a small u32. Verifying this tag is what
* keeps the new payload type from becoming a way to smuggle transaction bytes past the user.
*
* https://github.com/near/NEPs/blob/master/neps/nep-0413.md#how-to-ensure-the-message-is-not-a-transaction
*/
const NEP413_PREFIX_TAG = 2147484061;

// SCHEMA definitions
const SCHEMA = new (class BorshSchema {
Expand Down Expand Up @@ -177,6 +190,20 @@ const SCHEMA = new (class BorshSchema {
signature: this.Signature,
},
};
/**
* NEP-413 off-chain message payload. Field order is normative — it must match the
* serializer the client signs with, or the recovered bytes will not match `fatxn`.
* https://github.com/near/NEPs/blob/master/neps/nep-0413.md#input-interface
*/
NEP413Payload = {
struct: {
tag: "u32",
message: "string",
nonce: { array: { type: "u8", len: 32 } },
recipient: "string",
callbackUrl: { option: "string" },
},
};
})();

// UTILS
Expand All @@ -203,6 +230,76 @@ function decodeDelegateAction(encodedDelegateAction) {
return delegateAction;
}

/**
* Decode and validate a NEP-413 payload arriving on the authorize query string.
*
* The guard contract only verifies that `fatxn` equals the bytes the MPC is asked to sign — it
* never inspects them — so this function is the only place that establishes *what* the user is
* being asked to approve. Two checks carry that weight:
*
* 1. The borsh payload must deserialize cleanly under the NEP-413 schema, and the domain tag
* must be exactly NEP413_PREFIX_TAG so the bytes cannot also be a valid NEAR transaction.
* Never trust the caller to have set the tag.
* 2. There must be a message to show. Signing something the approval screen cannot display
* would defeat the consent guarantee the whole flow rests on.
*
* The recipient is not restricted. Under NEP-413 it names the application a message is addressed
* to, and the standard's protection is that the user sees it — which is why the approval screen
* always renders it. Restricting it here would be the wrong granularity anyway: a recipient
* belongs to an application, not to the tenant that hosts many of them.
* @param {string} encodedPayload Comma-separated byte string from the query.
* @returns {{payload: object, message: object|null}} The payload, plus the message parsed as JSON when it is JSON.
* @throws {Error} With a user-facing reason when any check fails.
*/
function decodeNep413Payload(encodedPayload) {
const bytes = Uint8Array.from(String(encodedPayload).split(",").map((value) => Number(value)));

let payload;
try {
payload = deserialize(SCHEMA.NEP413Payload, bytes);
} catch (e) {
throw new Error("Payload is not a valid NEP-413 message");
}

if (payload.tag !== NEP413_PREFIX_TAG) {
throw new Error("Payload is missing the NEP-413 domain tag");
}

if (typeof payload.message !== "string" || payload.message.length === 0) {
throw new Error("NEP-413 message is empty");
}

// A JSON message may be a structured payload the approval screen can render richly (NEAR
// Intents being the case we know about). Plain-text messages are equally valid and are
// shown verbatim, so failing to parse is not an error.
let message = null;
try {
const parsed = JSON.parse(payload.message);
if (parsed && typeof parsed === "object") message = parsed;
} catch (e) {
message = null;
}

return { payload, message };
}

function stringifyIntents(intents) {
return JSON.stringify(intents, (_, value) => (typeof value === "bigint" ? value.toString() : value), 2);
}

/**
* Extract the NEAR Intents body from a decoded message, when the message is one.
*
* Recognising it is what lets the approval screen show "transfer 1 USDC to X" instead of a wall
* of JSON. Anything else is not an error — it is just a message that gets displayed as text.
* @param {object|null} message The parsed message, or null when it was not JSON.
* @returns {Array|null} The intents array, or null when this is not an intents message.
*/
function extractIntents(message) {
if (!message || !Array.isArray(message.intents) || message.intents.length === 0) return null;
return message.intents;
}

function stringifyActions(actions) {
return JSON.stringify(
actions,
Expand Down Expand Up @@ -230,7 +327,9 @@ exports.onExecutePostLogin = async (event, api) => {
const isOnchainAudience = event.resource_server?.identifier === onchainAudience;
const hasTxParams = TRANSACTION_KEY in query;
const hasDelegateParams = DELEGATE_ACTION_KEY in query;
const hasSigningPayload = hasTxParams || hasDelegateParams;
const hasNep413Params = NEP413_KEY in query;
const payloadCount = [hasTxParams, hasDelegateParams, hasNep413Params].filter(Boolean).length;
const hasSigningPayload = payloadCount > 0;

if (isOnchainAudience && !hasSigningPayload) {
return api.access.deny("Signing audience requested without transaction payload");
Expand All @@ -240,6 +339,12 @@ exports.onExecutePostLogin = async (event, api) => {
}
if (!isOnchainAudience) return;

// Exactly one payload may be present. Accepting several and silently picking by precedence
// would let a caller show the user one payload while a different one lands in `fatxn`.
if (payloadCount > 1) {
return api.access.deny("Only one signing payload may be requested at a time");
}

// Strip OIDC profile scopes from the issued access token.
//
// @auth0/auth0-spa-js unions per-call scope with its built-in default ("openid profile email"),
Expand Down Expand Up @@ -280,7 +385,7 @@ exports.onExecutePostLogin = async (event, api) => {
"fatxn",
query.transaction.split(",").map((value) => Number(value)),
);
} else {
} else if (hasDelegateParams) {
const delegateAction = decodeDelegateAction(query.delegateAction);
api.prompt.render(event.secrets.DELEGATE_ACTION_FORM, {
fields: {
Expand All @@ -295,6 +400,38 @@ exports.onExecutePostLogin = async (event, api) => {
"fatxn",
query.delegateAction.split(",").map((value) => Number(value)),
);
} else {
let decoded;
try {
decoded = decodeNep413Payload(query.nep413);
} catch (error) {
// A payload we cannot decode is a payload we cannot show the user. Signing it would
// break the consent guarantee the whole flow rests on, so refuse instead.
return api.access.deny(error.message);
}

const { payload, message } = decoded;
const intents = extractIntents(message);

api.prompt.render(event.secrets.NEP413_FORM, {
fields: {
...branding,
// Always shown: per NEP-413 the recipient is the user's protection against a
// message being relayed to a third party, so it must be on screen either way.
recipient: payload.recipient,
callbackUrl: payload.callbackUrl ?? "",
// The raw message is always passed through. The form prefers the structured
// intents view when present and falls back to showing this verbatim.
message: payload.message,
signerId: (message && message.signer_id) ?? "",
deadline: (message && message.deadline) ?? "",
intents: intents ? stringifyIntents(intents) : "",
},
});
api.accessToken.setCustomClaim(
"fatxn",
query.nep413.split(",").map((value) => Number(value)),
);
}
};

Expand All @@ -317,5 +454,9 @@ exports.onContinuePostLogin = async (event, api) => {
// `onContinuePostLogin`; extra exports are inert in production.
exports.parseTransaction = parseTransaction;
exports.decodeDelegateAction = decodeDelegateAction;
exports.decodeNep413Payload = decodeNep413Payload;
exports.extractIntents = extractIntents;
exports.stringifyActions = stringifyActions;
exports.stringifyIntents = stringifyIntents;
exports.SCHEMA = SCHEMA;
exports.NEP413_PREFIX_TAG = NEP413_PREFIX_TAG;
1 change: 1 addition & 0 deletions packages/auth0/src/forms/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const HELPERS_PATH = path.join(FORMS_DIR, "shared", "helpers", "index.js");
const FORMS = [
{ name: "transaction", base: "transaction_form_base.json", out: "transaction_form.json" },
{ name: "delegate_action", base: "delegate_action_form_base.json", out: "delegate_action_form.json" },
{ name: "nep413", base: "nep413_form_base.json", out: "nep413_form.json" },
];

function readHelpersPreamble() {
Expand Down

Large diffs are not rendered by default.

Loading
Loading