Turnkey auth
This commit is contained in:
parent
c82e1110d3
commit
48552310e0
@ -3,10 +3,11 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@snowballtools/laconic-sdk": "^0.1.17",
|
|
||||||
"@graphql-tools/schema": "^10.0.2",
|
"@graphql-tools/schema": "^10.0.2",
|
||||||
"@graphql-tools/utils": "^10.0.12",
|
"@graphql-tools/utils": "^10.0.12",
|
||||||
"@octokit/oauth-app": "^6.1.0",
|
"@octokit/oauth-app": "^6.1.0",
|
||||||
|
"@snowballtools/laconic-sdk": "^0.1.17",
|
||||||
|
"@turnkey/sdk-server": "^0.1.0",
|
||||||
"@types/debug": "^4.1.5",
|
"@types/debug": "^4.1.5",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
|
@ -52,4 +52,10 @@ export interface Config {
|
|||||||
gitHub: GitHubConfig;
|
gitHub: GitHubConfig;
|
||||||
registryConfig: RegistryConfig;
|
registryConfig: RegistryConfig;
|
||||||
misc: MiscConfig;
|
misc: MiscConfig;
|
||||||
|
turnkey: {
|
||||||
|
apiBaseUrl: string;
|
||||||
|
apiPublicKey: string;
|
||||||
|
apiPrivateKey: string;
|
||||||
|
defaultOrganizationId: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,12 @@ export class User {
|
|||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
subOrgId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
turnkeyWalletId!: string;
|
||||||
|
|
||||||
@OneToMany(() => ProjectMember, (projectMember) => projectMember.project, {
|
@OneToMany(() => ProjectMember, (projectMember) => projectMember.project, {
|
||||||
cascade: ['soft-remove']
|
cascade: ['soft-remove']
|
||||||
})
|
})
|
||||||
|
@ -1,45 +1,50 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { SiweMessage } from 'siwe';
|
import { SiweMessage } from 'siwe';
|
||||||
import { Service } from '../service';
|
import { Service } from '../service';
|
||||||
|
import { authenticateUser, createUser } from '../turnkey-backend';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/validate', async (req, res) => {
|
router.get('/registration/:email', async (req, res) => {
|
||||||
const { message, signature, action } = req.body;
|
|
||||||
const { success, data } = await new SiweMessage(message).verify({
|
|
||||||
signature,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.send({ success });
|
|
||||||
}
|
|
||||||
|
|
||||||
const service: Service = req.app.get('service');
|
const service: Service = req.app.get('service');
|
||||||
const user = await service.getUserByEthAddress(data.address);
|
const user = await service.getUserByEmail(req.params.email);
|
||||||
|
if (user) {
|
||||||
if (action === 'signup') {
|
return res.send({ subOrganizationId: user?.subOrgId });
|
||||||
if (user) {
|
} else {
|
||||||
return res.send({ success: false, error: 'user_already_exists' });
|
return res.sendStatus(204);
|
||||||
}
|
|
||||||
const newUser = await service.loadOrCreateUser(data.address);
|
|
||||||
req.session.userId = newUser.id;
|
|
||||||
} else if (action === 'login') {
|
|
||||||
if (!user) {
|
|
||||||
return res.send({ success: false, error: 'user_not_found' });
|
|
||||||
}
|
|
||||||
req.session.userId = user.id;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
req.session.address = data.address;
|
router.post('/register', async (req, res) => {
|
||||||
|
const { email, challenge, attestation } = req.body;
|
||||||
|
const user = await createUser(req.app.get('service'), {
|
||||||
|
challenge,
|
||||||
|
attestation,
|
||||||
|
userEmail: email,
|
||||||
|
userName: email.split('@')[0],
|
||||||
|
});
|
||||||
|
req.session.userId = user.id;
|
||||||
|
res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
res.send({ success });
|
router.post('/authenticate', async (req, res) => {
|
||||||
|
const { signedWhoamiRequest } = req.body;
|
||||||
|
const user = await authenticateUser(
|
||||||
|
req.app.get('service'),
|
||||||
|
signedWhoamiRequest,
|
||||||
|
);
|
||||||
|
if (user) {
|
||||||
|
req.session.userId = user.id;
|
||||||
|
res.sendStatus(200);
|
||||||
|
} else {
|
||||||
|
res.sendStatus(401);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/session', (req, res) => {
|
router.get('/session', (req, res) => {
|
||||||
if (req.session.address) {
|
if (req.session.userId) {
|
||||||
res.send({
|
res.send({
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
address: req.session.address,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(401).send({ error: 'Unauthorized: No active session' });
|
res.status(401).send({ error: 'Unauthorized: No active session' });
|
||||||
|
@ -24,7 +24,6 @@ const log = debug('snowball:server');
|
|||||||
declare module 'express-session' {
|
declare module 'express-session' {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
userId: string;
|
userId: string;
|
||||||
address: string;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,14 +53,13 @@ export const createAndStartServer = async (
|
|||||||
context: async ({ req }) => {
|
context: async ({ req }) => {
|
||||||
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
|
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
|
||||||
|
|
||||||
const { address } = req.session;
|
const { userId } = req.session;
|
||||||
|
|
||||||
if (!address) {
|
if (!userId) {
|
||||||
throw new AuthenticationError('Unauthorized: No active session');
|
throw new AuthenticationError('Unauthorized: No active session');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find/create user from ETH address in request session
|
const user = await service.getUser(userId);
|
||||||
const user = await service.loadOrCreateUser(address);
|
|
||||||
|
|
||||||
return { user };
|
return { user };
|
||||||
},
|
},
|
||||||
|
@ -161,6 +161,22 @@ export class Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
return await this.db.getUser({
|
||||||
|
where: {
|
||||||
|
email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBySubOrgId(subOrgId: string): Promise<User | null> {
|
||||||
|
return await this.db.getUser({
|
||||||
|
where: {
|
||||||
|
subOrgId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getUserByEthAddress (ethAddress: string): Promise<User | null> {
|
async getUserByEthAddress (ethAddress: string): Promise<User | null> {
|
||||||
return await this.db.getUser({
|
return await this.db.getUser({
|
||||||
where: {
|
where: {
|
||||||
@ -169,28 +185,30 @@ export class Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOrCreateUser (ethAddress: string): Promise<User> {
|
async createUser (params: {
|
||||||
// Get user by ETH address
|
name: string
|
||||||
let user = await this.getUserByEthAddress(ethAddress);
|
email: string
|
||||||
|
subOrgId: string
|
||||||
|
ethAddress: string
|
||||||
|
turnkeyWalletId: string
|
||||||
|
}): Promise<User> {
|
||||||
|
const [org] = await this.db.getOrganizations({});
|
||||||
|
assert(org, 'No organizations exists in database');
|
||||||
|
|
||||||
if (!user) {
|
// Create user with new address
|
||||||
const [org] = await this.db.getOrganizations({});
|
const user = await this.db.addUser({
|
||||||
assert(org, 'No organizations exists in database');
|
email: params.email,
|
||||||
|
name: params.name,
|
||||||
|
subOrgId: params.subOrgId,
|
||||||
|
ethAddress: params.ethAddress,
|
||||||
|
isVerified: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Create user with new address
|
await this.db.addUserOrganization({
|
||||||
user = await this.db.addUser({
|
member: user,
|
||||||
email: `${ethAddress}@example.com`,
|
organization: org,
|
||||||
name: ethAddress,
|
role: Role.Owner
|
||||||
isVerified: true,
|
});
|
||||||
ethAddress
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.db.addUserOrganization({
|
|
||||||
member: user,
|
|
||||||
organization: org,
|
|
||||||
role: Role.Owner
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
130
packages/backend/src/turnkey-backend.ts
Normal file
130
packages/backend/src/turnkey-backend.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { Turnkey, TurnkeyApiTypes } from '@turnkey/sdk-server';
|
||||||
|
|
||||||
|
// Default path for the first Ethereum address in a new HD wallet.
|
||||||
|
// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form:
|
||||||
|
// m / purpose' / coin_type' / account' / change / address_index
|
||||||
|
// - Purpose is a constant set to 44' following the BIP43 recommendation.
|
||||||
|
// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md
|
||||||
|
// - Account, Change, and Address Index are set to 0
|
||||||
|
import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server';
|
||||||
|
import { getConfig } from './utils';
|
||||||
|
import { Service } from './service';
|
||||||
|
|
||||||
|
type TAttestation = TurnkeyApiTypes['v1Attestation'];
|
||||||
|
|
||||||
|
type CreateUserParams = {
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
challenge: string;
|
||||||
|
attestation: TAttestation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createUser(
|
||||||
|
service: Service,
|
||||||
|
{ userName, userEmail, challenge, attestation }: CreateUserParams,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (await service.getUserByEmail(userEmail)) {
|
||||||
|
throw new Error(`User already exists: ${userEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const turnkey = new Turnkey(config.turnkey);
|
||||||
|
|
||||||
|
const apiClient = turnkey.api();
|
||||||
|
|
||||||
|
const walletName = `Default ETH Wallet`;
|
||||||
|
|
||||||
|
const createSubOrgResponse = await apiClient.createSubOrganization({
|
||||||
|
subOrganizationName: `Default SubOrg for ${userEmail}`,
|
||||||
|
rootQuorumThreshold: 1,
|
||||||
|
rootUsers: [
|
||||||
|
{
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
apiKeys: [],
|
||||||
|
authenticators: [
|
||||||
|
{
|
||||||
|
authenticatorName: 'Passkey',
|
||||||
|
challenge,
|
||||||
|
attestation,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wallet: {
|
||||||
|
walletName: walletName,
|
||||||
|
accounts: DEFAULT_ETHEREUM_ACCOUNTS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId);
|
||||||
|
const wallet = refineNonNull(createSubOrgResponse.wallet);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
id: wallet.walletId,
|
||||||
|
address: wallet.addresses[0],
|
||||||
|
subOrgId: subOrgId,
|
||||||
|
};
|
||||||
|
console.log('Turnkey success', result);
|
||||||
|
|
||||||
|
const user = await service.createUser({
|
||||||
|
name: userName,
|
||||||
|
email: userEmail,
|
||||||
|
subOrgId,
|
||||||
|
ethAddress: wallet.addresses[0],
|
||||||
|
turnkeyWalletId: wallet.walletId,
|
||||||
|
});
|
||||||
|
console.log('New user', user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create user:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateUser(
|
||||||
|
service: Service,
|
||||||
|
signedWhoamiRequest: {
|
||||||
|
url: string;
|
||||||
|
body: any;
|
||||||
|
stamp: {
|
||||||
|
stampHeaderName: string;
|
||||||
|
stampHeaderValue: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const tkRes = await fetch(signedWhoamiRequest.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: signedWhoamiRequest.body,
|
||||||
|
headers: {
|
||||||
|
[signedWhoamiRequest.stamp.stampHeaderName]:
|
||||||
|
signedWhoamiRequest.stamp.stampHeaderValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('AUTH RESULT', tkRes.status);
|
||||||
|
if (tkRes.status !== 200) {
|
||||||
|
console.log(await tkRes.text());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const orgId = (await tkRes.json()).organizationId;
|
||||||
|
const user = await service.getUserBySubOrgId(orgId);
|
||||||
|
return user;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to authenticate:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refineNonNull<T>(
|
||||||
|
input: T | null | undefined,
|
||||||
|
errorMessage?: string,
|
||||||
|
): T {
|
||||||
|
if (input == null) {
|
||||||
|
throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
@ -36,6 +36,9 @@
|
|||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@turnkey/http": "^2.10.0",
|
||||||
|
"@turnkey/sdk-react": "^0.1.0",
|
||||||
|
"@turnkey/webauthn-stamper": "^0.5.0",
|
||||||
"@walletconnect/ethereum-provider": "^2.12.2",
|
"@walletconnect/ethereum-provider": "^2.12.2",
|
||||||
"@web3modal/siwe": "^4.0.5",
|
"@web3modal/siwe": "^4.0.5",
|
||||||
"@web3modal/wagmi": "^4.0.5",
|
"@web3modal/wagmi": "^4.0.5",
|
||||||
@ -83,4 +86,4 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -9,7 +9,6 @@ import { DotBorder } from 'components/shared/DotBorder';
|
|||||||
import { WavyBorder } from 'components/shared/WavyBorder';
|
import { WavyBorder } from 'components/shared/WavyBorder';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSnowball } from 'utils/use-snowball';
|
import { useSnowball } from 'utils/use-snowball';
|
||||||
import { CreatePasskey } from './CreatePasskey';
|
|
||||||
import { Input } from 'components/shared/Input';
|
import { Input } from 'components/shared/Input';
|
||||||
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
|
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
@ -17,6 +16,11 @@ import { useToast } from 'components/shared/Toast';
|
|||||||
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
|
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
|
||||||
import { signInWithEthereum } from 'utils/siwe';
|
import { signInWithEthereum } from 'utils/siwe';
|
||||||
import { logError } from 'utils/log-error';
|
import { logError } from 'utils/log-error';
|
||||||
|
import {
|
||||||
|
subOrganizationIdForEmail,
|
||||||
|
turnkeySignin,
|
||||||
|
turnkeySignup,
|
||||||
|
} from 'utils/turnkey-frontend';
|
||||||
|
|
||||||
type Provider = 'google' | 'github' | 'apple' | 'email';
|
type Provider = 'google' | 'github' | 'apple' | 'email';
|
||||||
|
|
||||||
@ -81,6 +85,23 @@ export const SignUp = ({ onDone }: Props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function authEmail() {
|
||||||
|
setProvider('email');
|
||||||
|
try {
|
||||||
|
const orgId = await subOrganizationIdForEmail(email);
|
||||||
|
console.log('orgId', orgId);
|
||||||
|
if (orgId) {
|
||||||
|
await turnkeySignin(orgId);
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} else {
|
||||||
|
await turnkeySignup(email);
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError({ type: 'email', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleSignupRedirect();
|
handleSignupRedirect();
|
||||||
}, []);
|
}, []);
|
||||||
@ -88,10 +109,6 @@ export const SignUp = ({ onDone }: Props) => {
|
|||||||
const loading = provider;
|
const loading = provider;
|
||||||
const emailValid = /.@./.test(email);
|
const emailValid = /.@./.test(email);
|
||||||
|
|
||||||
if (provider === 'email') {
|
|
||||||
return <CreatePasskey onDone={onDone} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
|
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
|
||||||
@ -200,9 +217,15 @@ export const SignUp = ({ onDone }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
rightIcon={<ArrowRightCircleFilledIcon height="16" />}
|
rightIcon={
|
||||||
|
loading && loading === 'email' ? (
|
||||||
|
<LoaderIcon className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowRightCircleFilledIcon height="16" />
|
||||||
|
)
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setProvider('email');
|
authEmail();
|
||||||
}}
|
}}
|
||||||
variant={'secondary'}
|
variant={'secondary'}
|
||||||
disabled={!email || !emailValid || !!loading}
|
disabled={!email || !emailValid || !!loading}
|
||||||
|
144
packages/frontend/src/utils/turnkey-frontend.ts
Normal file
144
packages/frontend/src/utils/turnkey-frontend.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { TurnkeyClient, getWebAuthnAttestation } from '@turnkey/http';
|
||||||
|
import { WebauthnStamper } from '@turnkey/webauthn-stamper';
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_SERVER_URL;
|
||||||
|
|
||||||
|
const PASSKEY_WALLET_RPID = import.meta.env.VITE_PASSKEY_WALLET_RPID!;
|
||||||
|
const TURNKEY_BASE_URL = import.meta.env.VITE_TURNKEY_API_BASE_URL!;
|
||||||
|
|
||||||
|
// All algorithms can be found here: https://www.iana.org/assignments/cose/cose.xhtml#algorithms
|
||||||
|
// We only support ES256, which is listed here
|
||||||
|
const es256 = -7;
|
||||||
|
|
||||||
|
export async function subOrganizationIdForEmail(
|
||||||
|
email: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await fetch(`${baseUrl}/auth/registration/${email}`);
|
||||||
|
|
||||||
|
// If API returns a non-empty 200, this email maps to an existing user.
|
||||||
|
if (res.status == 200) {
|
||||||
|
return (await res.json()).subOrganizationId;
|
||||||
|
} else if (res.status === 204) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected response from registration status endpoint: ${res.status}: ${await res.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This signup function triggers a webauthn "create" ceremony and POSTs the resulting attestation to the backend
|
||||||
|
* The backend uses Turnkey to create a brand new sub-organization with a new private key.
|
||||||
|
* @param email user email
|
||||||
|
*/
|
||||||
|
export async function turnkeySignup(email: string) {
|
||||||
|
const challenge = generateRandomBuffer();
|
||||||
|
const authenticatorUserId = generateRandomBuffer();
|
||||||
|
|
||||||
|
// An example of possible options can be found here:
|
||||||
|
// https://www.w3.org/TR/webauthn-2/#sctn-sample-registration
|
||||||
|
const attestation = await getWebAuthnAttestation({
|
||||||
|
publicKey: {
|
||||||
|
rp: {
|
||||||
|
id: PASSKEY_WALLET_RPID,
|
||||||
|
name: 'Demo Passkey Wallet',
|
||||||
|
},
|
||||||
|
challenge,
|
||||||
|
pubKeyCredParams: [
|
||||||
|
{
|
||||||
|
// This constant designates the type of credential we want to create.
|
||||||
|
// The enum only supports one value, "public-key"
|
||||||
|
// https://www.w3.org/TR/webauthn-2/#enumdef-publickeycredentialtype
|
||||||
|
type: 'public-key',
|
||||||
|
alg: es256,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
user: {
|
||||||
|
id: authenticatorUserId,
|
||||||
|
name: email,
|
||||||
|
displayName: email,
|
||||||
|
},
|
||||||
|
authenticatorSelection: {
|
||||||
|
requireResidentKey: true,
|
||||||
|
residentKey: 'required',
|
||||||
|
userVerification: 'preferred',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
attestation,
|
||||||
|
challenge: base64UrlEncode(challenge),
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected response from registration endpoint: ${res.status}: ${await res.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In order to know whether the user is logged in for `subOrganizationId`, we make them sign
|
||||||
|
// a request for Turnkey's "whoami" endpoint.
|
||||||
|
// The backend will then forward to Turnkey and get a response on whether the stamp was valid.
|
||||||
|
// If this is successful, our backend will issue a logged in session.
|
||||||
|
export async function turnkeySignin(subOrganizationId: string) {
|
||||||
|
const stamper = new WebauthnStamper({
|
||||||
|
rpId: PASSKEY_WALLET_RPID,
|
||||||
|
});
|
||||||
|
const client = new TurnkeyClient(
|
||||||
|
{
|
||||||
|
baseUrl: TURNKEY_BASE_URL,
|
||||||
|
},
|
||||||
|
stamper,
|
||||||
|
);
|
||||||
|
|
||||||
|
var signedRequest;
|
||||||
|
try {
|
||||||
|
signedRequest = await client.stampGetWhoami({
|
||||||
|
organizationId: subOrganizationId,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Error during webauthn prompt: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/auth/authenticate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
signedWhoamiRequest: signedRequest,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected response from authentication endpoint: ${res.status}: ${await res.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateRandomBuffer = (): ArrayBuffer => {
|
||||||
|
const arr = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
return arr.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64UrlEncode = (challenge: ArrayBuffer): string => {
|
||||||
|
return Buffer.from(challenge)
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
};
|
39
yarn.lock
39
yarn.lock
@ -4183,7 +4183,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@turnkey/encoding/-/encoding-0.1.0.tgz#85461c3aa11c70882cc2b0853f5db40f576c3ac9"
|
resolved "https://registry.yarnpkg.com/@turnkey/encoding/-/encoding-0.1.0.tgz#85461c3aa11c70882cc2b0853f5db40f576c3ac9"
|
||||||
integrity sha512-aLmLrWtvV1k9UyGzuzMpBFdwleCH8VpzXIriusVMrFLiQp+4uHCS9cwrEG1glg3207ewWFDtvgj31qLoJS29pg==
|
integrity sha512-aLmLrWtvV1k9UyGzuzMpBFdwleCH8VpzXIriusVMrFLiQp+4uHCS9cwrEG1glg3207ewWFDtvgj31qLoJS29pg==
|
||||||
|
|
||||||
"@turnkey/http@2.10.0", "@turnkey/http@^2.6.2":
|
"@turnkey/http@2.10.0", "@turnkey/http@^2.10.0", "@turnkey/http@^2.6.2":
|
||||||
version "2.10.0"
|
version "2.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/@turnkey/http/-/http-2.10.0.tgz#9e8d0dc6279719e3efaf5ae1df7dc9fd5a111ecf"
|
resolved "https://registry.yarnpkg.com/@turnkey/http/-/http-2.10.0.tgz#9e8d0dc6279719e3efaf5ae1df7dc9fd5a111ecf"
|
||||||
integrity sha512-5I2VwOzxYGxmSy8UOZn8rsV23gmK8v93KqNZ/mjf4GrFQ69q8LCaAFmrH1Zo+/J7eq0/GQdxNqBHfJKLp5iihw==
|
integrity sha512-5I2VwOzxYGxmSy8UOZn8rsV23gmK8v93KqNZ/mjf4GrFQ69q8LCaAFmrH1Zo+/J7eq0/GQdxNqBHfJKLp5iihw==
|
||||||
@ -4193,11 +4193,42 @@
|
|||||||
"@turnkey/webauthn-stamper" "0.5.0"
|
"@turnkey/webauthn-stamper" "0.5.0"
|
||||||
cross-fetch "^3.1.5"
|
cross-fetch "^3.1.5"
|
||||||
|
|
||||||
"@turnkey/iframe-stamper@^1.0.0":
|
"@turnkey/iframe-stamper@1.2.0", "@turnkey/iframe-stamper@^1.0.0":
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@turnkey/iframe-stamper/-/iframe-stamper-1.2.0.tgz#bba478e391a266833f1a5960b9f1df9de5934fb8"
|
resolved "https://registry.yarnpkg.com/@turnkey/iframe-stamper/-/iframe-stamper-1.2.0.tgz#bba478e391a266833f1a5960b9f1df9de5934fb8"
|
||||||
integrity sha512-OXbCVVzypa0AXa6dcNpfu8Q0xY/sq2nGXwhesrUQmE7V5I5nYYHZE3sQv54lErToX6H6YyDR9Z1DuPzEUkYTjw==
|
integrity sha512-OXbCVVzypa0AXa6dcNpfu8Q0xY/sq2nGXwhesrUQmE7V5I5nYYHZE3sQv54lErToX6H6YyDR9Z1DuPzEUkYTjw==
|
||||||
|
|
||||||
|
"@turnkey/sdk-browser@0.1.0":
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@turnkey/sdk-browser/-/sdk-browser-0.1.0.tgz#8bab3f0600644025b8935d8e6e8c0e193d3a763b"
|
||||||
|
integrity sha512-afgn/pP/HQ4fMoVk7q/vPbNCAQKlEuIREX++tdovl1Wr8IXGuzXDz8sXm4R1T6RU/gl+KzsE9B1EfKpr6JoriQ==
|
||||||
|
dependencies:
|
||||||
|
"@turnkey/api-key-stamper" "0.4.0"
|
||||||
|
"@turnkey/http" "2.10.0"
|
||||||
|
"@turnkey/iframe-stamper" "1.2.0"
|
||||||
|
"@turnkey/webauthn-stamper" "0.5.0"
|
||||||
|
buffer "^6.0.3"
|
||||||
|
cross-fetch "^3.1.5"
|
||||||
|
elliptic "^6.5.5"
|
||||||
|
|
||||||
|
"@turnkey/sdk-react@^0.1.0":
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@turnkey/sdk-react/-/sdk-react-0.1.0.tgz#ec7016410db6c5fec5863f5d7844340b59cee133"
|
||||||
|
integrity sha512-a7CzXtQJJGlB5TrK9Cean/Ry4EmTkdqDns5UWk3Vg6DAoCaUmKFmNHqFA4bT+JDvbkXRbmhkmsjh/hRrh4IHCA==
|
||||||
|
dependencies:
|
||||||
|
"@turnkey/sdk-browser" "0.1.0"
|
||||||
|
|
||||||
|
"@turnkey/sdk-server@^0.1.0":
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@turnkey/sdk-server/-/sdk-server-0.1.0.tgz#899d199e2382a2ea4a933d1f408d96bee83d18d1"
|
||||||
|
integrity sha512-INg+r1p955OsS33OkV+AEqkhIhP2QcB20bAhS+oVPskGjin93/02RvfniJvArU30arhYTKOgeHrUHWmI6lVuaw==
|
||||||
|
dependencies:
|
||||||
|
"@turnkey/api-key-stamper" "0.4.0"
|
||||||
|
"@turnkey/http" "2.10.0"
|
||||||
|
buffer "^6.0.3"
|
||||||
|
cross-fetch "^3.1.5"
|
||||||
|
elliptic "^6.5.5"
|
||||||
|
|
||||||
"@turnkey/viem@^0.4.8":
|
"@turnkey/viem@^0.4.8":
|
||||||
version "0.4.14"
|
version "0.4.14"
|
||||||
resolved "https://registry.yarnpkg.com/@turnkey/viem/-/viem-0.4.14.tgz#bbc60bd8ce478401e9e7900357de7edc792acd14"
|
resolved "https://registry.yarnpkg.com/@turnkey/viem/-/viem-0.4.14.tgz#bbc60bd8ce478401e9e7900357de7edc792acd14"
|
||||||
@ -4208,7 +4239,7 @@
|
|||||||
cross-fetch "^4.0.0"
|
cross-fetch "^4.0.0"
|
||||||
typescript "^5.1"
|
typescript "^5.1"
|
||||||
|
|
||||||
"@turnkey/webauthn-stamper@0.5.0":
|
"@turnkey/webauthn-stamper@0.5.0", "@turnkey/webauthn-stamper@^0.5.0":
|
||||||
version "0.5.0"
|
version "0.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/@turnkey/webauthn-stamper/-/webauthn-stamper-0.5.0.tgz#014b8c20b1732af49dacb04f396edf010d3b7f47"
|
resolved "https://registry.yarnpkg.com/@turnkey/webauthn-stamper/-/webauthn-stamper-0.5.0.tgz#014b8c20b1732af49dacb04f396edf010d3b7f47"
|
||||||
integrity sha512-iUbTUwD4f4ibdLy5PWWb7ITEz4S4VAP9/mNjFhoRY3cKVVTDfmykrVTKjPOIHWzDgAmLtgrLvySIIC9ZBVENBw==
|
integrity sha512-iUbTUwD4f4ibdLy5PWWb7ITEz4S4VAP9/mNjFhoRY3cKVVTDfmykrVTKjPOIHWzDgAmLtgrLvySIIC9ZBVENBw==
|
||||||
@ -7203,7 +7234,7 @@ elliptic@6.5.4:
|
|||||||
minimalistic-assert "^1.0.1"
|
minimalistic-assert "^1.0.1"
|
||||||
minimalistic-crypto-utils "^1.0.1"
|
minimalistic-crypto-utils "^1.0.1"
|
||||||
|
|
||||||
elliptic@^6.4.0, elliptic@^6.5.2, elliptic@^6.5.3, elliptic@^6.5.4:
|
elliptic@^6.4.0, elliptic@^6.5.2, elliptic@^6.5.3, elliptic@^6.5.4, elliptic@^6.5.5:
|
||||||
version "6.5.5"
|
version "6.5.5"
|
||||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.5.tgz#c715e09f78b6923977610d4c2346d6ce22e6dded"
|
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.5.tgz#c715e09f78b6923977610d4c2346d6ce22e6dded"
|
||||||
integrity sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw==
|
integrity sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw==
|
||||||
|
Loading…
Reference in New Issue
Block a user