-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
π auth: implement passkey authentication
- Loading branch information
1 parent
37dd24f
commit 0646ae0
Showing
6 changed files
with
227 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |