Skip to content

Commit

Permalink
πŸ›‚ auth: implement passkey authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
sebipap authored and cruzdanilo committed Jun 10, 2024
1 parent 37dd24f commit 0646ae0
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 0 deletions.
Binary file modified bun.lockb
Binary file not shown.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@
"@peculiar/webcrypto": "^1.5.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@sentry/react-native": "^5.23.1",
"@simplewebauthn/browser": "^10.0.0",
"@tamagui/config": "^1.100.3",
"@tanstack/react-form": "^0.20.3",
"@tanstack/react-query": "^5.40.1",
"@tanstack/zod-form-adapter": "^0.20.3",
"abitype": "^1.0.2",
"base64-arraybuffer": "^1.0.2",
"expo": "~51.0.11",
Expand Down Expand Up @@ -76,6 +79,7 @@
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/plugin-transform-private-methods": "^7.24.7",
"@simplewebauthn/types": "^10.0.0",
"@tamagui/babel-plugin": "^1.100.3",
"@types/babel__core": "^7.20.5",
"@types/eslint": "^8.56.10",
Expand Down
2 changes: 2 additions & 0 deletions utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ export const rpId = __DEV__ && Platform.OS === "web" ? "localhost" : "exactly.ap

if (!process.env.EXPO_PUBLIC_ALCHEMY_API_KEY) throw new Error("missing alchemy api key");
if (!process.env.EXPO_PUBLIC_ALCHEMY_GAS_POLICY_ID) throw new Error("missing alchemy gas policy");
if (!process.env.EXPO_PUBLIC_SERVER_URL) throw new Error("missing server url");

export const alchemyAPIKey = process.env.EXPO_PUBLIC_ALCHEMY_API_KEY;
export const alchemyGasPolicyId = process.env.EXPO_PUBLIC_ALCHEMY_GAS_POLICY_ID;
export const oneSignalAppId = process.env.EXPO_PUBLIC_ONE_SIGNAL_APP_ID;
export const serverURL = process.env.EXPO_PUBLIC_SERVER_URL;

export { optimismSepolia as chain } from "@alchemy/aa-core";
84 changes: 84 additions & 0 deletions utils/createAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { startRegistration } from "@simplewebauthn/browser";
import { ApiKeyStamper } from "@turnkey/api-key-stamper";
import { TurnkeyClient, createActivityPoller } from "@turnkey/http";
import { deviceName } from "expo-device";
import { UAParser } from "ua-parser-js";

import base64URLEncode from "./base64URLEncode";
import { rpId, turnkeyAPIPrivateKey, turnkeyAPIPublicKey, turnkeyOrganizationId } from "./constants";
import generateRandomBuffer from "./generateRandomBuffer";
import handleError from "./handleError";
import { registrationOptions, verifyRegistration } from "./server/client";
import uppercase from "./uppercase";

export default async function createAccount() {
const challengeID = base64URLEncode(generateRandomBuffer());
const { challenge } = await registrationOptions(challengeID);
const name = `exactly, ${new Date().toISOString()}`;
const attestation = await startRegistration({
rp: { id: rpId, name: "exactly" },
user: { id: challenge, name, displayName: name },
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: { requireResidentKey: true, residentKey: "required", userVerification: "required" },
challenge,
});
const client = new TurnkeyClient(
{ baseUrl: "https://api.turnkey.com" },
new ApiKeyStamper({ apiPublicKey: turnkeyAPIPublicKey, apiPrivateKey: turnkeyAPIPrivateKey }),
);
const activityPoller = createActivityPoller({ client, requestFn: client.createSubOrganization });
try {
const {
result: { createSubOrganizationResultV4 },
} = await activityPoller({
type: "ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V4",
timestampMs: String(Date.now()),
organizationId: turnkeyOrganizationId,
parameters: {
subOrganizationName: attestation.id,
rootQuorumThreshold: 1,
rootUsers: [
{
apiKeys: [],
userName: "account",
authenticators: [
{
authenticatorName: deviceName ?? new UAParser(navigator.userAgent).getBrowser().name ?? "unknown",
challenge,
attestation: {
credentialId: attestation.id,
attestationObject: attestation.response.attestationObject,
clientDataJson: attestation.response.clientDataJSON,
transports:
attestation.response.transports?.map(
(t) =>
`AUTHENTICATOR_TRANSPORT_${uppercase(t === "smart-card" || t === "cable" ? "usb" : t)}` as const,
) || [],
},
},
],
},
],
wallet: {
walletName: "default",
accounts: [
{
curve: "CURVE_SECP256K1",
addressFormat: "ADDRESS_FORMAT_ETHEREUM",
pathFormat: "PATH_FORMAT_BIP32",
path: "m/44'/60'/0'/0/0",
},
],
},
},
});
if (!createSubOrganizationResultV4?.wallet?.addresses[0]) throw new Error("sub-org creation failed");

const verifyRegistrationResponse = await verifyRegistration({ challengeID, attestation });
const { verified } = verifyRegistrationResponse;

alert(verified ? "Account created" : "Account creation failed");
} catch (error) {
handleError(error);
}
}
134 changes: 134 additions & 0 deletions utils/server/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { startAuthentication } from "@simplewebauthn/browser";
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/types";

import type { Card, CreateUserForm, User } from "../../server/utils/types";
import base64URLEncode from "../base64URLEncode";
import { serverURL } from "../constants";
import generateRandomBuffer from "../generateRandomBuffer";

async function accessToken() {
const challengeID = base64URLEncode(generateRandomBuffer());
const optionsResponse = await fetch(`${serverURL}/auth/generate-authentication-options?challengeID=${challengeID}`);
const options = (await optionsResponse.json()) as PublicKeyCredentialRequestOptionsJSON;
const authenticationResponse = await startAuthentication(options);
const verificationResp = await fetch(`${serverURL}/auth/verify-authentication?challengeID=${challengeID}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(authenticationResponse),
});
const { verified, token } = (await verificationResp.json()) as { verified: boolean; token: string };

if (!verified) throw new Error("Authentication failed");

return token;
}

async function pomelo<Response>(
url: `/${string}`,
init: Omit<RequestInit, "method" | "body"> & { method: "GET" | "POST" | "PATCH"; body?: object },
authenticated: boolean,
) {
const _request = new Request(`${serverURL}${url}`, {
...init,
headers: {
...init.headers,
"Content-Type": "application/json; charset=UTF-8",
...(authenticated ? { Authorization: `Bearer ${await accessToken()}` } : {}),
},
body: JSON.stringify(init.body),
});

const response = await fetch(_request);
const json: unknown = await response.json();

if (!response.ok) {
const {
error: { details },
} = json as { error: { details: { detail: string }[] } };

const detailsText = details.map(({ detail }) => detail).join(", ");
switch (response.status) {
case 400: {
throw new Error(`invalid request: ${detailsText}`);
}
case 401: {
throw new Error("unauthorized");
}
case 403: {
throw new Error("forbidden");
}
case 404: {
throw new Error(`not found: ${detailsText}`);
}
default: {
throw new Error(`unexpected error: ${response.status}`); // TODO report to sentry
}
}
}

return json as Response;
}

export async function getCards() {
return pomelo<Card[]>("/card", { method: "GET" }, true);
}

export async function createCard() {
return pomelo<Card>(
"/card",
{
method: "POST",
},
true,
);
}

export async function createUser(user: CreateUserForm) {
return pomelo<User>("/user", { method: "POST", body: user }, true);
}

export async function getUser() {
return pomelo<User>(
"/user",
{
method: "GET",
},
true,
);
}

export async function verifyRegistration({
challengeID,
attestation,
}: {
challengeID: string;
attestation: RegistrationResponseJSON;
}) {
return pomelo<{ verified: boolean }>(
`/auth/verify-registration?challengeID=${challengeID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: attestation,
},
false,
);
}

export async function registrationOptions(challengeID: string) {
return await pomelo<PublicKeyCredentialCreationOptionsJSON>(
`/auth/generate-registration-options?challengeID=${challengeID}`,
{
method: "GET",
},
false,
);
}
3 changes: 3 additions & 0 deletions utils/uppercase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const uppercase = <T extends string>(string: T) => string.toUpperCase() as Uppercase<T>;

export default uppercase;

0 comments on commit 0646ae0

Please sign in to comment.