Skip to main content

Authentication

Authentication in dapps is essential for verifying if a user has permission to perform specific actions. While the process is straightforward with regular Rell applications, it becomes more complex with FT4 due to support for native and EVM signatures and multiple keys for an FT4 account. However, the complexity is abstracted, and authentication can be easily implemented using the authenticate operation.

Authenticate function

To authenticate a user within an operation, use the following code:

operation foo() {
val account = ft4.auth.authenticate();
}

However, calling this operation from the client side will result in an error:

await session.call(op("foo"));
// Can not find auth handler for operation <foo>

To enable FT4 authentication, an auth handler must be defined for the operation using the auth_handler extension function. For more information on extendable functions, refer to the extendable functions documentation.

@extend(ft4.auth.auth_handler)
function () = ft4.auth.add_auth_handler(
scope = rell.meta(foo).mount_name,
flags = []
);

operation foo() {
val account = ft4.auth.authenticate();
}

The scope parameter specifies the scope of the auth handler, which, in this case, is limited to the foo operation. The flags parameter defines the auth flags required for user authorization to call the operation. In this example, no auth flags are needed.

When the operation is called, the user will be prompted to sign the operation in the MetaMask window.

Strict Authentication Mode

The authenticate function has been enhanced with a strict mode, which is enabled by default. This mode requires an exact match for the operation name in authentication handlers, enhancing security by ensuring explicit and precise authentication definitions.

Usage:

  • Default Strict Mode: Simply call authenticate() for strict mode.
  • Non-Strict Mode: Use authenticate(is_strict = false) to disable strict mode and allow scope path traversal for authentication handlers.

Strict mode ensures accurate and secure authentication by mandating explicit handler definitions for each operation. This reduces the risk of unintended permissions or security oversights.

Custom auth messages

In the previous example, there are no details about argument names in the message. The message is automatically generated from the operation name and argument values. If we want to show friendlier messages, we can define a custom message formatter. The following example demonstrates how to create a custom message formatter for the bar operation:

function bar_message(gtv) {
val args = struct<bar>.from_gtv(gtv);
val arg1 = args.arg1;
val arg2 = args.arg2;
return "Please sign the message\nin order to call BAR operation\nwith arguments:\n\n- arg1: %s\n- arg2: %s".format(arg1, arg2);
}

@extend(ft4.auth.auth_handler)
function () = ft4.auth.add_auth_handler(
scope = rell.meta(bar).mount_name,
flags = [],
formatter = bar_message(*)
);

operation bar(arg1: text, arg2: integer) {
val account = ft4.auth.authenticate();
}

The custom message formatter bar_message expects one parameter, which is a gtv encoded list of operation arguments. The operation arguments can be easily decoded using struct<op_name>.from_gtv(gtv). In this case, the arguments for the bar operation are decoded using struct<bar>.from_gtv(gtv).

Our custom message formatter now also displays argument names. Please note that {account_id} and {auth_descriptor_id} are replaced with real values during the authentication process.

To apply the custom message formatter, specify it in the auth handler definition:

@extend(ft4.auth.auth_handler)
function () = ft4.auth.add_auth_handler(
scope = rell.meta(bar).mount_name,
flags = [],
formatter = bar_message(*)
);

Application scope auth handler

Often, multiple operations within an application have the same authentication requirements (auth flags). In such cases, an application scope auth handler can be defined to handle these operations. If the scope property is omitted when defining an auth handler, it becomes an application scope handler and is used for operations without operation scope auth handlers. The following example demonstrates how to define an application scope auth handler:

module;

import lib.ft4.ft4_basic_dev.*;

query get_all_accounts

() = ft4.acc.account @* {} (.id);

@extend(ft4.auth.auth_handler)
function () = ft4.auth.add_auth_handler(
flags = []
);

operation foo() {
val account = ft4.acc.authenticate();
}

operation bar(arg1: text, arg2: integer) {
val account = ft4.acc.authenticate();
}

In this example, the auth_handler function becomes the application scope auth handler without a scope definition. It is used for the foo and bar operations since they need their operation scope auth handlers.

note

Suppose one of the messages (related to foo and bar operation) is rejected. In that case, the whole transaction build process gets interrupted, and none of the operations are called on the blockchain.

caution

You can't define multiple application scope auth handlers, as it would result in a runtime error during authentication.

Mount name scope auth handler

Apart from operation and application scope auth handlers, there is also a mount name scope auth handler. This allows the use of a common auth handler for a mount name.

For example, suppose you have this folder structure in your project:

src/
|-dir1/
| |-dir2/
| | |-inner.rell
| |
| |-mid.rell
|
|-outer.rell

Suppose that inner.rell defines operations foo and bar. mid.rell imports inner.rell, and outer.rell imports mid.rell. In this example, outer is the entry point of the dapp. This would be the code:

inner.rell

@mount("mid.inner")
module;

operation foo() {
// ...
}

operation bar() {
// ...
}

mid.rell

@mount("mid")
module;
import dir2.inner;

outer.rell

module;
import dir1.mid;

chromia.yml

blockchains:
hello:
module: outer
#...

The operations are now mounted as mid.inner.foo and mid.inner.bar.

You can define an auth handler for all operations mounted in scope inner by doing so:

@extend(ft4.auth.auth_handler)
function () = ft4.auth.add_auth_handler(
scope = "mid.inner",
flags = ["T"]
);

The code above means that both foo and bar need the T flag to be called. You could also define it with scope = "mid", which would cover all operations contained in mid.rell, too.

If you define a mount name scope for two different mount names, the most specific one will be used. For example, suppose you define:

  • auth handler "A" for scope one.two;
  • auth handler "T" for scope one.two.three.four.

These operations will need the A flag to be called:

  • one.two.foo
  • one.two.three.foo
  • one.two.other.modules.foo

These operations will need the T flag to be called:

  • one.two.three.four.foo
  • one.two.three.four.other.modules.foo

Detailed information about mount name scope auth handlers'll be added in the future, including support for {operation} and {args} placeholders in the auth message template.

Login manager

When calling operations frequently in a client app, requiring a user to sign each operation with MetaMask can result in a poor user experience. To address this issue, the login manager can be used to generate a new key and add it to the user's account. This enables non—interactive signing of operations using the directly accessible new key. However, caution must be exercised when adding auth flags to prevent potential security risks. it's crucial never to add admin flags or flags that could compromise user assets if the generated key pair is compromised.

The following example demonstrates how to create a login manager, add a disposable key to an account using the login function, and call the foo and bar operations. This time, only a message signing is required to add a new auth descriptor, while the foo and bar operations are called without MetaMask signatures.

createWeb3ProviderEvmKeyStore(window.ethereum).then(
async (store: EvmKeyStore) => {
const { getAccounts, getLoginManager } = createKeyStoreInteractor(
client,
store
);

const accounts = await getAccounts();

if (!accounts.length) return;

const session = await getLoginManager().login({
accountId: accounts[0].id,
});

await session.call(op("foo"), op("bar", "some other text", 123456));
}
);

However, if a transfer operation is called, the disposable auth descriptor'll still require a signature because it doesn't have the "T" (transfer) flag.

To sign the transfer transaction using the disposable auth descriptor, modify the login function call and add the "T" flag to the auth descriptor:

const session = await getLoginManager().login({
accountId: accounts[0].id,
flags: ["T"],
});

Now, the disposable auth descriptor'll be used to sign the transfer transaction instead of the master auth descriptor.

caution

Disposable keys aren't securely stored. Therefore, never add auth flags that could lead to asset compromise if the disposable key pair is compromised.