Access Controls Tutorial
May 09, 2023
Produced for Motoko Bootcamp, this guide shows a minimal example of how to add authentication to your frontend and manage updates in a Motoko canister.
The code for this example is available at https://github.com/krpeacock/access-control-tutorial. Here is a video I also produced for the 2023 Motoko Bootcamp:
Concepts
What is "Access Control"? It is a broad term that essentially amounts to "who is allowed to access or change stuff". In cryptography, we accomplish this using cryptographic signatures
. For the Internet Computer, we take these signatures and transform the public key into a (relatively) short format called a Principal, which looks like a series of characters, separated by a hyphen (-
).
Since the Internet Computer automatically validates the signature used to make a call before it even reaches your canister's code, this makes it very convenient to use these Principals as a way to identify a unique user. Notably, Principals can be both a user, or another canister. We use Principals in a number of contexts - the controllers
that are authorized to make changes to an application are Principals, and the caller
of any method will also be represented as a Principal. Thanks to the properties of cryptographic signatures, you can be confident that a Principal cannot be faked. It will always be the same person, unless they have had their private key compromised.
Backend Logic
In Motoko, there are a few tricks you can use for access control purposes. Let's start with the creator
of the canister itself.
Creator ID
The simplest way you can set up your Motoko canister is to declare an actor with something like this:
actor {
public query func sayHi() : async Text {
"Hi";
}
}
This is a great place to start, but there is more information you can gather if you need it. First, you can learn the Principal that was responsible for creating the canister in the first place. If you are using dfx, you can check the Principal by labeling the actor as shared
.
shared ({ caller }) actor self () {
// ...
A pattern that is common for clarity purposes is to rename the caller
to "creator"
, which looks like this:
shared ({ caller = creator }) actor self () {
// ...
Then, inside your code, you have access to both creator
, which will tell you the Principal that created the canister, as well as self
, which will give you the ability to reference the canister's own Principal.
Caller ID
The same pattern is available for your methods. If we take the sayHi func above, we can modify it into a "shared" query func, which will give the function access to the caller's Principal.
public shared query ({caller}) func sayHi() : async Text {
"Hi, " # Principal.toText(caller);
}
Frontend Logic
In the code sample I've created here, I have written a simple application that tracks the number of times that a particular caller has called the increment
method.
Most of the logic is simply about enabling this behavior, but I do want to share two useful authentication strategies that the example shows off:
Ed25519 from Seed
Since it's possible to generate an Ed25519KeyIdentity using a seed
, this means we can use a passphrase or some other secret to generate an identity. The example does this with a password
input, and enables you to have reproducible identities using a simple text input. Here's the snippet that makes this work:
export function seedToIdentity(seed) {
const seedBuf = new Uint8Array(new ArrayBuffer(32));
if (seed.length && seed.length > 0 && seed.length <= 32) {
seedBuf.set(new TextEncoder().encode(seed));
return Ed25519KeyIdentity.generate(seedBuf);
}
return null;
}
Internet Identity Web Component
The II web component is a project I spent some time on a few months back. It takes the @dfinity/auth-client
package and abstracts it with a web component that you can drop into your page with a little less configuration and some nice features. After you log in, the identity
will be accessible by just checking the button's identity
attribute.
export const prepareLoginBotton = async (renderCb) => {
if (!customElements.get("ii-login-button")) {
customElements.define("ii-login-button", LoginButton);
}
// Once the login button is ready, we can configure it to use Internet Identity
loginButton?.addEventListener("ready", async (event) => {
if (
window.location.host.includes("localhost") ||
window.location.host.includes("127.0.0.1")
) {
loginButton.configure({
loginOptions: {
identityProvider: `http://${process.env.CANISTER_ID_INTERNET_IDENTITY}.localhost:4943`,
},
});
}
});
loginButton?.addEventListener("login", async (event) => {
const identity = loginButton?.identity;
window.identity = identity;
renderCb();
});
// ...
};