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 = auth.authenticate();
}

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

await session.call(op("foo"));
// Cannot 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(auth.auth_handler)
function () = auth.add_auth_handler(
scope = rell.meta(foo).mount_name,
flags = []
);

operation foo() {
val account = 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.

If you are building a web application, when the operation is called, the user will be prompted to sign it in the MetaMask window.

Overridable auth handlers

Sometimes, when you define an auth handler for an operation, for example, if you are developing your library on top of FT4, you should allow developers to override that auth handler with their implementation, which suits their use case better. For that, we provide an alternative function for registering an auth handler called add_overridable_auth_handler, which works precisely like add_auth_handler with the only difference that it allows your user to call add_auth_handler with the same scope as was registered with the overridable auth handler, something that would cause an error if you would have used add_auth_handler.

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

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

If the user adds their version of an auth handler, it will authorize the operation; if they don't, your version will be used.

note

While add_overridable_auth_handler will let the user add another auth handler with the same name, this only works once. For example, if you override auth handlers for one of the auth handlers built into FT4, your users would not be able to override that auth handler.

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(auth.auth_handler)
function () = auth.add_auth_handler(
scope = rell.meta(bar).mount_name,
flags = [],
message = bar_message(*)
);

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

The custom message formatter bar_message expects one parameter: 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 the message will be decorated to contain the blockchain rid of the current blockchain and a nonce value to prevent replay attacks.

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

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

Custom resolver

When your authentication requirements are more complex, static flags cannot determine whether a user can act. An example from the FT4 library would be the ft4.delete_auth_descriptor operation. To delete an auth descriptor, the auth descriptor that authorizes the operation must have the A flag unless the auth descriptor to be deleted is the same one as the one authorizing the operation, in which case it can have any flags. In other words, an auth descriptor can always delete itself.

To handle a scenario like this, one can pass a resolver function as follows:

function bar_resolver(
args: gtv,
account_id: byte_array,
auth_descriptor_ids: list<byte_array>
): byte_array? {
// Only the main auth descriptor for the account can perform this operation
for (ad_id in auth_descriptor_ids) {
val main_ad_id = accounts.main_auth_descriptor @ {
.account.id == account_id
} .auth_descriptor.id;
return if (main_ad_id in auth_descriptor_ids) main_ad_id else null;
}

return null;
}

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

The resolver function accepts the following arguments:

  • args: The arguments that were passed to the bar operation. Much like the args is passed to the message function
  • account_id: The account that is being targeted for authentication
  • auth_descriptor_ids: A list of possible auth descriptors to use for authenticating this operation

The return type of this function is byte_array? and the returned value should be one of the auth descriptor IDs passed as input, namely the first auth descriptor that satisfies whatever auth requirements there might be. The operation will then be performed using this auth descriptor. If the list does not contain any auth descriptors that fulfill the auth requirements, the function must return null; in that case, the authenticate() call will fail with a permission error.

Application scope auth handler

Multiple operations within an application often have the exact authentication requirements (auth flags, for example). 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.accounts;
import lib.ft4.auth;

query get_all_accounts() = accounts.account @* {} (.id);

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

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

operation bar(arg1: text, arg2: integer) {
val account = accounts.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.

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(auth.auth_handler)
function () = 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 also cover all operations contained in mid.rell.

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 will be added later, including support for {operation} and {args} placeholders in the auth message template.

Disposable keys

When calling operations frequently in a web app, requiring a user to sign each operation with MetaMask can result in a poor user experience. To address this issue, disposable keys can be generated and added 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.

It would be best to destroy the disposable key using the logout function. It is essential to do so, or the account might fill up with disposable auth descriptors, and the user might not be allowed to access it in the future.

caution

Don't forget to use the logout function! The users won't be able to access your dapp if the auth descriptor per account limit is reached.

For more information on this limit, check out the Auth descriptors and access control topic.

The following example demonstrates adding a disposable key to an account using the login function and calling the foo and bar operations. This time, only one message signing is required to add a new auth descriptor, while the foo and bar operations are called without signing.

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

const accounts = await getAccounts();

if (!accounts.length) return;

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

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

// more calls here ...

await logout();
});

However, the disposable auth descriptor will still require a signature if a transfer operation is called 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, logout } = await login({
accountId: accounts[0].id,
config: {
flags: ["T"],
},
});

The disposable auth descriptor will be used to sign the transfer transaction instead of the master auth descriptor.

One can also add rules to be applied to the new auth descriptor, such as for how long it should be valid:

const { session } = await login({
accountId: accounts[0].id,
config: {
flags: ["T"],
rules: ttlLoginRule(minutes(30)),
},
});

If no login config is provided. The default config will be used. The default timeout is then one day and the flags will be set to []

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.