forked from cerc-io/snowballtools-base
Turnkey auth
This commit is contained in:
@@ -3,10 +3,11 @@
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@snowballtools/laconic-sdk": "^0.1.17",
|
||||
"@graphql-tools/schema": "^10.0.2",
|
||||
"@graphql-tools/utils": "^10.0.12",
|
||||
"@octokit/oauth-app": "^6.1.0",
|
||||
"@snowballtools/laconic-sdk": "^0.1.17",
|
||||
"@turnkey/sdk-server": "^0.1.0",
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.0",
|
||||
|
||||
@@ -52,4 +52,10 @@ export interface Config {
|
||||
gitHub: GitHubConfig;
|
||||
registryConfig: RegistryConfig;
|
||||
misc: MiscConfig;
|
||||
turnkey: {
|
||||
apiBaseUrl: string;
|
||||
apiPublicKey: string;
|
||||
apiPrivateKey: string;
|
||||
defaultOrganizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ export class User {
|
||||
@CreateDateColumn()
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column()
|
||||
subOrgId!: string;
|
||||
|
||||
@Column()
|
||||
turnkeyWalletId!: string;
|
||||
|
||||
@OneToMany(() => ProjectMember, (projectMember) => projectMember.project, {
|
||||
cascade: ['soft-remove']
|
||||
})
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import { SiweMessage } from 'siwe';
|
||||
import { Service } from '../service';
|
||||
import { authenticateUser, createUser } from '../turnkey-backend';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/validate', async (req, res) => {
|
||||
const { message, signature, action } = req.body;
|
||||
const { success, data } = await new SiweMessage(message).verify({
|
||||
signature,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
return res.send({ success });
|
||||
}
|
||||
|
||||
router.get('/registration/:email', async (req, res) => {
|
||||
const service: Service = req.app.get('service');
|
||||
const user = await service.getUserByEthAddress(data.address);
|
||||
|
||||
if (action === 'signup') {
|
||||
if (user) {
|
||||
return res.send({ success: false, error: 'user_already_exists' });
|
||||
}
|
||||
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;
|
||||
const user = await service.getUserByEmail(req.params.email);
|
||||
if (user) {
|
||||
return res.send({ subOrganizationId: user?.subOrgId });
|
||||
} else {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (req.session.address) {
|
||||
if (req.session.userId) {
|
||||
res.send({
|
||||
userId: req.session.userId,
|
||||
address: req.session.address,
|
||||
});
|
||||
} else {
|
||||
res.status(401).send({ error: 'Unauthorized: No active session' });
|
||||
|
||||
@@ -24,7 +24,6 @@ const log = debug('snowball:server');
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId: string;
|
||||
address: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,14 +53,13 @@ export const createAndStartServer = async (
|
||||
context: async ({ req }) => {
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Find/create user from ETH address in request session
|
||||
const user = await service.loadOrCreateUser(address);
|
||||
const user = await service.getUser(userId);
|
||||
|
||||
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> {
|
||||
return await this.db.getUser({
|
||||
where: {
|
||||
@@ -169,28 +185,30 @@ export class Service {
|
||||
});
|
||||
}
|
||||
|
||||
async loadOrCreateUser (ethAddress: string): Promise<User> {
|
||||
// Get user by ETH address
|
||||
let user = await this.getUserByEthAddress(ethAddress);
|
||||
async createUser (params: {
|
||||
name: string
|
||||
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) {
|
||||
const [org] = await this.db.getOrganizations({});
|
||||
assert(org, 'No organizations exists in database');
|
||||
// Create user with new address
|
||||
const user = await this.db.addUser({
|
||||
email: params.email,
|
||||
name: params.name,
|
||||
subOrgId: params.subOrgId,
|
||||
ethAddress: params.ethAddress,
|
||||
isVerified: true,
|
||||
});
|
||||
|
||||
// Create user with new address
|
||||
user = await this.db.addUser({
|
||||
email: `${ethAddress}@example.com`,
|
||||
name: ethAddress,
|
||||
isVerified: true,
|
||||
ethAddress
|
||||
});
|
||||
|
||||
await this.db.addUserOrganization({
|
||||
member: user,
|
||||
organization: org,
|
||||
role: Role.Owner
|
||||
});
|
||||
}
|
||||
await this.db.addUserOrganization({
|
||||
member: user,
|
||||
organization: org,
|
||||
role: Role.Owner
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user