Skip to content

Commit 8e6cea9

Browse files
authored
Merge pull request #30 from windingtree/feat/node-trpc-auth
Node API authorization
2 parents 4e1c1c9 + e03b19c commit 8e6cea9

18 files changed

+1969
-1010
lines changed

package.json

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,22 +73,18 @@
7373
},
7474
"devDependencies": {
7575
"@types/node": "^18.15.11",
76-
"@types/mocha": "^10.0.1",
77-
"@types/chai": "^4.3.5",
7876
"@types/debug": "^4.1.8",
79-
"@types/chai-as-promised": "^7.1.5",
8077
"@types/luxon": "^3.3.0",
81-
"mocha": "^10.2.0",
82-
"chai": "^4.3.7",
83-
"chai-as-promised": "^7.1.1",
78+
"@types/jsonwebtoken": "^9.0.2",
79+
"@types/bcryptjs": "^2.4.2",
8480
"ts-node": "^10.9.1",
8581
"typescript": "^5.1.3",
86-
"semantic-release": "^21.0.2",
82+
"semantic-release": "^21.0.5",
8783
"semantic-release-cli": "^5.4.4",
8884
"@semantic-release/changelog": "^6.0.3",
89-
"eslint": "^8.41.0",
90-
"@typescript-eslint/eslint-plugin": "^5.59.11",
91-
"@typescript-eslint/parser": "^5.59.11",
85+
"eslint": "^8.43.0",
86+
"@typescript-eslint/eslint-plugin": "^5.60.0",
87+
"@typescript-eslint/parser": "^5.60.0",
9288
"prettier": "^2.8.8",
9389
"husky": "^8.0.3",
9490
"git-cz": "^4.9.0",
@@ -97,7 +93,7 @@
9793
"lint-staged": "^13.2.2",
9894
"c8": "^8.0.0",
9995
"typedoc": "^0.24.8",
100-
"dotenv": "^16.1.4",
96+
"dotenv": "^16.3.1",
10197
"vitest": "^0.32.2"
10298
},
10399
"dependencies": {
@@ -107,23 +103,28 @@
107103
"@chainsafe/libp2p-yamux": "^4.0.2",
108104
"@libp2p/mplex": "^8.0.3",
109105
"@libp2p/websockets": "^6.0.3",
110-
"ethers": "^6.4.0",
111-
"viem": "^1.0.7",
106+
"viem": "^1.1.7",
112107
"luxon": "^3.3.0",
113108
"h3-js": "^4.1.0",
114109
"debug": "^4.3.4",
115-
"@windingtree/contracts": "^1.0.0-beta.12"
110+
"@windingtree/contracts": "^1.0.0-beta.12",
111+
"@trpc/server": "^10.32.0",
112+
"@trpc/client": "^10.32.0",
113+
"jsonwebtoken": "^9.0.0",
114+
"zod": "^3.21.4",
115+
"bcryptjs": "^2.4.3"
116116
},
117117
"scripts": {
118118
"clean": "rm -rf ./lib",
119119
"build": "npm run clean && tsc -p ./tsconfig.build.json",
120120
"test": "vitest --run test",
121+
"test:rel": "vitest related --run",
121122
"semantic-release": "semantic-release",
122123
"lint": "eslint . --ext .ts",
123124
"lint:fix": "eslint . --ext .ts --fix && prettier --write .",
124125
"prepare": "husky install",
125126
"commit": "git-cz -S",
126-
"coverage": "c8 --all --exclude coverage --exclude lib --exclude test yarn test && c8 report --all --exclude coverage --exclude lib --exclude test -r html",
127+
"coverage": "c8 --all --exclude coverage --exclude lib --exclude test --exclude examples --exclude temp yarn test && c8 report -r html",
127128
"example:server": "node --experimental-specifier-resolution=node --loader ts-node/esm ./examples/server/index.ts",
128129
"example:client": "yarn --cwd ./examples/client dev",
129130
"example:node": "node --experimental-specifier-resolution=node --loader ts-node/esm ./examples/node/index.ts",

src/node/api/db.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { z } from 'zod';
2+
import { hash } from 'bcryptjs';
3+
import { Storage } from '../../storage/index.js';
4+
5+
export { compare as comparePassword } from 'bcryptjs';
6+
7+
/**
8+
* Interface defining the properties of a User object stored in storage.
9+
*/
10+
export interface User {
11+
/** The username of the user */
12+
login: string;
13+
/** The hashed password of the user */
14+
hashedPassword: string;
15+
/** Flag which indicate that the use is admin */
16+
isAdmin?: boolean;
17+
/** The optional JSON Web Token of the user */
18+
jwt?: string;
19+
}
20+
21+
/**
22+
* Validation schema for user creation API input.
23+
* It validates that the input contains a nonempty `login` and `password`.
24+
*/
25+
export const UserInputSchema = z.object({
26+
login: z.string().nonempty(),
27+
password: z.union([z.string().nonempty(), z.string().startsWith('0x')]),
28+
});
29+
30+
/**
31+
* Type definition for User registration input,
32+
* inferred from UserInputSchema.
33+
*/
34+
export type UserInputType = z.infer<typeof UserInputSchema>;
35+
36+
/**
37+
* Interface defining the properties of UsersDb initialization options.
38+
*/
39+
export interface UsersDbOptions {
40+
/** Instance of storage used for persisting the state of the API server */
41+
storage: Storage;
42+
/** Prefix used for the storage key to avoid potential key collisions */
43+
prefix: string;
44+
}
45+
46+
export class UsersDb {
47+
/** Storage instance for persisting the state of the API server */
48+
storage: Storage;
49+
/** Specific key prefix for the storage key to avoid potential key collisions */
50+
prefix: string;
51+
52+
/**
53+
* Creates an instance of UsersDb.
54+
* Initializes an instance of UsersDb with given options.
55+
*
56+
* @param {UsersDbOptions} options
57+
* @memberof NodeApiServer
58+
*/
59+
constructor(options: UsersDbOptions) {
60+
const { storage, prefix } = options;
61+
62+
// TODO Validate NodeApiServerOptions
63+
64+
this.prefix = `${prefix}_api_users_`;
65+
this.storage = storage;
66+
}
67+
68+
/**
69+
* Hashes the given password with the given salt.
70+
*
71+
* @static
72+
* @param {string} password The password to be hashed
73+
* @returns {string} The hashed password
74+
* @memberof UsersDb
75+
*/
76+
static async hashPassword(password: string): Promise<string> {
77+
return await hash(password, 10);
78+
}
79+
80+
/**
81+
* Generates a prefixed login key
82+
*
83+
* @private
84+
* @param {string} login The login for which the key is generated
85+
* @returns {string} The prefixed login key
86+
* @memberof UsersDb
87+
*/
88+
private loginKey(login: string): string {
89+
return `${this.prefix}${login}`;
90+
}
91+
92+
/**
93+
* Retrieves the user with the given login from storage.
94+
*
95+
* @param {string} login The login of the user to be retrieved
96+
* @returns {Promise<User>} The User object associated with the given login
97+
* @throws Will throw an error if the user is not found
98+
* @memberof UsersDb
99+
*/
100+
async get(login: string): Promise<User> {
101+
const user = await this.storage.get<User>(this.loginKey(login));
102+
103+
if (!user) {
104+
throw new Error(`User ${login} not found`);
105+
}
106+
107+
return user;
108+
}
109+
110+
/**
111+
* Adds a new user to the storage.
112+
*
113+
* @param {string} login The login of the user to be added
114+
* @param {string} password The password of the user to be added
115+
* @param {boolean} [isAdmin] Option which indicate that the use is admin
116+
* @returns {Promise<void>}
117+
* @throws Will throw an error if a user with the same login already exists
118+
* @memberof UsersDb
119+
*/
120+
async add(login: string, password: string, isAdmin = false): Promise<void> {
121+
const knownUser = await this.storage.get<User>(this.loginKey(login));
122+
123+
// Check if the user already exists
124+
if (knownUser) {
125+
throw new Error(`User ${login} already exists`);
126+
}
127+
128+
// Save the user into the storage
129+
await this.storage.set<User>(this.loginKey(login), {
130+
login,
131+
hashedPassword: await UsersDb.hashPassword(password),
132+
isAdmin,
133+
});
134+
}
135+
136+
/**
137+
* Updates the record of the user in the storage
138+
*
139+
* @param {User} user The user object
140+
* @returns {Promise<void>}
141+
* @memberof UsersDb
142+
*/
143+
async set(user: User): Promise<void> {
144+
await this.storage.set<User>(this.loginKey(user.login), user);
145+
}
146+
147+
/**
148+
* Deletes the user from storage
149+
*
150+
* @param {string} login The user login name
151+
* @returns {Promise<void>}
152+
* @throws Will throw an error if not possible to delete the user
153+
* @memberof UsersDb
154+
*/
155+
async delete(login: string): Promise<void> {
156+
const deleted = await this.storage.delete(this.loginKey(login));
157+
158+
if (!deleted) {
159+
throw new Error(`Unable to delete user ${login}`);
160+
}
161+
}
162+
}

src/node/api/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './db.js';
2+
export * from './server.js';
3+
export * from './router/admin.js';
4+
export * from './router/user.js';

src/node/api/router/admin.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { TRPCError } from '@trpc/server';
2+
import { Address, Hash, verifyTypedData } from 'viem';
3+
import { TypedDataDomain } from 'abitype';
4+
import { Account } from '../../../index.js';
5+
import { User, UserInputSchema } from '../db.js';
6+
import { router, procedure } from '../index.js';
7+
import { createLogger } from '../../../utils/logger.js';
8+
9+
const logger = createLogger('AdminRouter');
10+
11+
/**
12+
* Typed domain for the admin signature
13+
*/
14+
export const adminDomain: TypedDataDomain = {
15+
name: 'Admin',
16+
version: '1',
17+
};
18+
19+
/** EIP-712 JSON schema types for node API server auth operation */
20+
export const adminAuthEip712Types = {
21+
Admin: [
22+
{
23+
name: 'signer',
24+
type: 'address',
25+
},
26+
],
27+
} as const;
28+
29+
/**
30+
* Create EIP-712 signature for checkIn/Out voucher
31+
*
32+
* @param {Account} account Ethereum local account
33+
* @returns {Promise<Hash>}
34+
*/
35+
export const createAdminSignature = async (account: Account): Promise<Hash> =>
36+
await account.signTypedData({
37+
domain: adminDomain,
38+
types: adminAuthEip712Types,
39+
primaryType: 'Admin',
40+
message: {
41+
signer: account.address,
42+
},
43+
});
44+
45+
/**
46+
* Verification of the admins signature
47+
*
48+
* @param {Address} signer The address of admin account
49+
* @param {Hash} signature Admins EIP-712 signature
50+
* @returns {Promise<boolean>}
51+
*/
52+
export const verifyAdminSignature = async (
53+
signer: Address,
54+
signature: Hash,
55+
): Promise<boolean> =>
56+
await verifyTypedData({
57+
address: signer,
58+
domain: adminDomain,
59+
types: adminAuthEip712Types,
60+
primaryType: 'Admin',
61+
message: {
62+
signer,
63+
},
64+
signature,
65+
});
66+
67+
/**
68+
* A router defining procedures for admin management.
69+
*/
70+
export const adminRouter = router({
71+
/**
72+
* Register a new admin.
73+
* Throws an error if the user already exists or signature provided is invalid.
74+
*/
75+
register: procedure
76+
.input(UserInputSchema)
77+
.mutation(async ({ input, ctx }) => {
78+
const { login, password } = input;
79+
const { server } = ctx;
80+
81+
try {
82+
if (
83+
!server.ownerAccount ||
84+
!(await verifyAdminSignature(server.ownerAccount, password as Hash))
85+
) {
86+
throw new Error('Invalid signature');
87+
}
88+
89+
await server.users.add(login, password, true);
90+
logger.trace(`Admin registered`);
91+
} catch (error) {
92+
logger.error('admin.register', error);
93+
throw new TRPCError({
94+
code: 'BAD_REQUEST',
95+
message: (error as Error).message,
96+
});
97+
}
98+
}),
99+
100+
/**
101+
* Log in an existing admin.
102+
* If successful, generates a new access token and sends it in the response.
103+
*/
104+
login: procedure.input(UserInputSchema).mutation(async ({ input, ctx }) => {
105+
const { login, password } = input;
106+
const { server, updateAccessToken } = ctx;
107+
let user: User;
108+
109+
try {
110+
logger.trace(`Trying to log in admin ${login}`);
111+
user = await server.users.get(login);
112+
} catch (error) {
113+
logger.error('admin.login', error);
114+
throw new TRPCError({
115+
code: 'BAD_REQUEST',
116+
message: (error as Error).message,
117+
});
118+
}
119+
120+
if (
121+
!server.ownerAccount ||
122+
!(await verifyAdminSignature(server.ownerAccount, password as Hash))
123+
) {
124+
throw new TRPCError({
125+
code: 'UNAUTHORIZED',
126+
message: 'Invalid signature',
127+
});
128+
}
129+
130+
await updateAccessToken(user);
131+
}),
132+
});

0 commit comments

Comments
 (0)