@powerhousedao/reactor-api 1.13.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powerhousedao/reactor-api",
3
- "version": "1.13.0",
3
+ "version": "1.14.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,12 +17,15 @@
17
17
  "@types/body-parser": "^1.19.5",
18
18
  "@types/cors": "^2.8.17",
19
19
  "@types/express": "^5.0.0",
20
+ "@types/jsonwebtoken": "^9.0.7",
21
+ "@types/ms": "^0.7.34",
22
+ "@types/node": "^20",
20
23
  "@types/pg": "^8.11.10",
21
24
  "esbuild": "^0.24.0",
22
25
  "graphql-tag": "^2.12.6",
23
- "@powerhousedao/scalars": "1.15.0",
24
- "document-drive": "1.12.1",
25
- "document-model": "2.14.0"
26
+ "document-model": "2.15.0",
27
+ "document-drive": "1.13.1",
28
+ "@powerhousedao/scalars": "1.16.0"
26
29
  },
27
30
  "dependencies": {
28
31
  "@apollo/server": "^4.11.0",
@@ -32,17 +35,23 @@
32
35
  "@powerhousedao/analytics-engine-knex": "^0.4.0",
33
36
  "body-parser": "^1.20.3",
34
37
  "cors": "^2.8.5",
38
+ "dotenv": "^16.4.5",
35
39
  "drizzle-kit": "^0.25.0",
36
40
  "drizzle-orm": "^0.34.1",
37
41
  "express": "^4.21.1",
38
42
  "graphql": "^16.9.0",
39
43
  "graphql-request": "^6.1.0",
44
+ "jsonwebtoken": "^9.0.2",
40
45
  "knex": "^3.1.0",
41
46
  "knex-pglite": "^0.10.0",
47
+ "ms": "^2.1.3",
42
48
  "nanoevents": "^9.0.0",
43
49
  "pg": "^8.13.0",
50
+ "siwe": "^2.3.2",
44
51
  "uuid": "^9.0.1",
45
- "document-model-libs": "1.124.0"
52
+ "wildcard-match": "^5.1.3",
53
+ "zod": "^3.24.1",
54
+ "document-model-libs": "1.125.1"
46
55
  },
47
56
  "scripts": {
48
57
  "build": "tsup",
@@ -0,0 +1,30 @@
1
+ import ms from "ms";
2
+
3
+ export const getJwtSecret = (): string => {
4
+ if (!process.env.JWT_SECRET) {
5
+ if (process.env.NODE_ENV === "production") {
6
+ throw new Error("JWT_SECRET is not defined");
7
+ }
8
+ }
9
+ return process.env.JWT_SECRET || "dev";
10
+ };
11
+
12
+ export const getJwtExpirationPeriod = (): string => {
13
+ if (!process.env.JWT_EXPIRATION_PERIOD) {
14
+ return "7d";
15
+ }
16
+ // check if number of seconds is provided
17
+ const expirationSeconds = Number(process.env.JWT_EXPIRATION_PERIOD);
18
+ if (!Number.isNaN(expirationSeconds)) {
19
+ // https://www.npmjs.com/package/jsonwebtoken for `expiresIn` format
20
+ return ms(expirationSeconds * 1000);
21
+ }
22
+ // check if a valid time string is provided
23
+ const expirationMs = ms(process.env.JWT_EXPIRATION_PERIOD);
24
+ if (!expirationMs) {
25
+ throw new Error(
26
+ "JWT_EXPIRATION_PERIOD must be a number of seconds or ms string",
27
+ );
28
+ }
29
+ return process.env.JWT_EXPIRATION_PERIOD;
30
+ };
@@ -0,0 +1,15 @@
1
+ import dotenv from "dotenv";
2
+ import { getJwtExpirationPeriod, getJwtSecret } from "./getters";
3
+
4
+ dotenv.config();
5
+
6
+ export const JWT_SECRET = getJwtSecret();
7
+ export const PORT = process.env.PORT ?? "3000";
8
+ export const isDevelopment = process.env.NODE_ENV === "development";
9
+ export const AUTH_SIGNUP_ENABLED = Boolean(process.env.AUTH_SIGNUP_ENABLED);
10
+ export const JWT_EXPIRATION_PERIOD: string = getJwtExpirationPeriod();
11
+ export const API_ORIGIN = process.env.API_ORIGIN || `http://localhost:${PORT}`;
12
+ export const CORS_ORIGINS = process.env.ORIGINS?.split(",") ?? [
13
+ "https://studio.apollographql.com",
14
+ "https://ph-switchboard-nginx-prod-c84ebf8c6e3b.herokuapp.com",
15
+ ];
@@ -0,0 +1,323 @@
1
+ import { GraphQLResolverMap } from "@apollo/subgraph/dist/schema-helper";
2
+ import { generateUUID } from "document-drive";
3
+ import { GraphQLError } from "graphql";
4
+ import { gql } from "graphql-tag";
5
+ import { SiweMessage } from "siwe";
6
+ import { Db } from "src/types";
7
+ import { Subgraph } from "../base";
8
+ import { Context } from "../types";
9
+ import { AuthContext, Challenge, Session, SessionInput } from "./types";
10
+ import { generateTokenAndSession } from "./utils/helpers";
11
+ import {
12
+ authenticate,
13
+ createAuthenticationSession,
14
+ verifySignature,
15
+ } from "./utils/session";
16
+ import { getUser, upsertUser } from "./utils/user";
17
+
18
+ export class AuthSubgraph extends Subgraph {
19
+ name = "auth";
20
+ typeDefs = gql`
21
+ type Query {
22
+ me: User
23
+ sessions: [Session!]!
24
+ }
25
+
26
+ type Mutation {
27
+ createChallenge(address: String!): Challenge
28
+ solveChallenge(nonce: String!, signature: String!): SessionOutput
29
+ createSession(session: SessionInput!): SessionOutput
30
+ revokeSession(sessionId: String!): SessionOutput
31
+ }
32
+
33
+ type User {
34
+ address: String!
35
+ createdAt: DateTime!
36
+ }
37
+
38
+ type Challenge {
39
+ nonce: String!
40
+ message: String!
41
+ hex: String!
42
+ }
43
+
44
+ type SessionOutput {
45
+ id: ID!
46
+ token: String
47
+ }
48
+
49
+ type Session {
50
+ id: ID!
51
+ userId: String!
52
+ address: String!
53
+ expiresAt: DateTime!
54
+ createdAt: DateTime!
55
+ updatedAt: DateTime!
56
+ referenceTokenId: String!
57
+ createdBy: String!
58
+ referenceExpiryDate: DateTime
59
+ isUserCreated: Boolean!
60
+ name: String
61
+ allowedOrigins: String
62
+ revokedAt: DateTime
63
+ }
64
+
65
+ input SessionInput {
66
+ expiryDurationSeconds: Int
67
+ name: String!
68
+ allowedOrigins: String!
69
+ }
70
+ `;
71
+
72
+ resolvers: GraphQLResolverMap<AuthContext> = {
73
+ Query: {
74
+ me: async (_, __, ctx) => {
75
+ const db = ctx.db as Db;
76
+ const session = await authenticate(ctx);
77
+ const user = await getUser(db, session.createdBy);
78
+ return user;
79
+ },
80
+ sessions: async (_: unknown, __: unknown, ctx: Context) => {
81
+ const session = await authenticate(ctx);
82
+ const db = ctx.db as Db;
83
+ const sessions = await db<Session>("Session")
84
+ .select()
85
+ .where("createdBy", session.createdBy)
86
+ .orderBy("createdAt", "desc");
87
+ return sessions;
88
+ },
89
+ },
90
+ Mutation: {
91
+ createChallenge: async (
92
+ _: unknown,
93
+ { address }: { address: string },
94
+ ctx: Context,
95
+ ) => {
96
+ const db = ctx.db as Db;
97
+ const { API_ORIGIN } = process.env;
98
+
99
+ const origin = API_ORIGIN ?? "http://localhost:3000";
100
+ const domain = new URL(origin).hostname;
101
+
102
+ if (!domain) {
103
+ throw new GraphQLError("Invalid origin");
104
+ }
105
+
106
+ const nonce = generateUUID().replace(/-/g, "");
107
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
108
+ const message = new SiweMessage({
109
+ address,
110
+ nonce,
111
+ uri: origin,
112
+ domain,
113
+ version: "1",
114
+ chainId: 1,
115
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
116
+ }).prepareMessage();
117
+ const textToHex = (textMessage: string) =>
118
+ `0x${Buffer.from(textMessage, "utf8").toString("hex")}`;
119
+ if (!message || typeof message !== "string") {
120
+ throw new GraphQLError("Failed to create challenge");
121
+ }
122
+ const hexMessage = textToHex(message);
123
+
124
+ await db("Challenge").insert({
125
+ nonce,
126
+ message,
127
+ updatedAt: new Date().toISOString(),
128
+ });
129
+
130
+ return {
131
+ nonce,
132
+ message,
133
+ hex: hexMessage,
134
+ };
135
+ },
136
+ solveChallenge: async (
137
+ _: unknown,
138
+ { nonce, signature }: { nonce: string; signature: string },
139
+ ctx: Context,
140
+ ) => {
141
+ const db = ctx.db as Db;
142
+ const data = await db.transaction(async (tx) => {
143
+ const [challenge] = await tx<Challenge>("Challenge")
144
+ .select()
145
+ .where("nonce", nonce);
146
+
147
+ // check that challenge with this nonce exists
148
+ if (!challenge) {
149
+ throw new GraphQLError("The nonce is not known");
150
+ }
151
+
152
+ // check that challenge was not used
153
+ if (challenge.signature) {
154
+ throw new GraphQLError("The signature was already used");
155
+ }
156
+
157
+ // verify signature
158
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
159
+ const parsedMessage = new SiweMessage(challenge.message);
160
+ try {
161
+ await verifySignature(parsedMessage, signature);
162
+ } catch (error) {
163
+ throw new GraphQLError("Signature validation has failed");
164
+ }
165
+
166
+ // mark challenge as used
167
+ await tx<Challenge>("Challenge")
168
+ .update({
169
+ signature,
170
+ })
171
+ .where("nonce", nonce);
172
+
173
+ // create user and session
174
+ const user = await upsertUser(db, {
175
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
176
+ address: parsedMessage.address as `0x${string}`,
177
+ networkId: "1",
178
+ chainId: 1,
179
+ });
180
+
181
+ if (!user) {
182
+ throw new GraphQLError("User not found");
183
+ }
184
+
185
+ const tokenAndSession = await createAuthenticationSession(
186
+ db,
187
+ user.address,
188
+ );
189
+
190
+ return tokenAndSession;
191
+ });
192
+
193
+ return data;
194
+ },
195
+ createSession: async (
196
+ _: unknown,
197
+ { session }: { session: SessionInput },
198
+ ctx: Context,
199
+ ) => {
200
+ const db = ctx.db as Db;
201
+ const sessionAuth = await authenticate(ctx);
202
+ const tokenAndSession = await generateTokenAndSession(
203
+ db,
204
+ session,
205
+ sessionAuth.createdBy,
206
+ sessionAuth.isUserCreated,
207
+ );
208
+ if (!tokenAndSession) {
209
+ throw new GraphQLError("Failed to create session");
210
+ }
211
+ return tokenAndSession;
212
+ },
213
+ revokeSession: async (
214
+ _: unknown,
215
+ { sessionId }: { sessionId: string },
216
+ ctx: Context,
217
+ ): Promise<{ id: string }> => {
218
+ const user = await authenticate(ctx);
219
+ const db = ctx.db as Db;
220
+ const [session] = await db<Session>("Session").select().where({
221
+ id: sessionId,
222
+ createdBy: user.createdBy,
223
+ });
224
+
225
+ if (!session) {
226
+ throw new GraphQLError("Session not found", {
227
+ extensions: { code: "SESSION_NOT_FOUND" },
228
+ });
229
+ }
230
+ if (session.revokedAt !== null) {
231
+ throw new GraphQLError("Session already revoked", {
232
+ extensions: { code: "SESSION_ALREADY_REVOKED" },
233
+ });
234
+ }
235
+
236
+ await db<Session>("Session")
237
+ .update({
238
+ revokedAt: new Date().toISOString(),
239
+ })
240
+ .where({
241
+ id: sessionId,
242
+ createdBy: user.createdBy,
243
+ });
244
+
245
+ return { id: session.id };
246
+ },
247
+ },
248
+ };
249
+
250
+ async onSetup() {
251
+ await super.onSetup();
252
+ await this.#createTables();
253
+ this.subgraphManager.setAdditionalContextFields({
254
+ session: async (ctx: Context) => {
255
+ const bearerToken = ctx.headers.authorization?.split(" ")[1];
256
+ if (!bearerToken) {
257
+ return null;
258
+ }
259
+
260
+ // @todo: optimize and cache this
261
+ const db = ctx.db as Db;
262
+ const [session] = await db<Session>("Session")
263
+ .select()
264
+ .where({
265
+ referenceTokenId: bearerToken,
266
+ })
267
+ .limit(1);
268
+
269
+ return session;
270
+ },
271
+ });
272
+ }
273
+
274
+ async #createTables() {
275
+ if (!(await this.operationalStore.schema.hasTable("User"))) {
276
+ await this.operationalStore.schema.createTable("User", (table) => {
277
+ table.string("address").primary().notNullable();
278
+ table.timestamp("createdAt").notNullable().defaultTo(`now()`);
279
+ table.timestamp("updatedAt").notNullable().defaultTo(`now()`);
280
+ });
281
+ }
282
+
283
+ if (!(await this.operationalStore.schema.hasTable("Session"))) {
284
+ await this.operationalStore.schema.createTable("Session", (table) => {
285
+ table.string("id").primary().notNullable();
286
+ table.timestamp("createdAt").notNullable().defaultTo(`now()`);
287
+ table.string("createdBy").notNullable();
288
+ table.string("referenceExpiryDate");
289
+ table.string("name");
290
+ table.string("revokedAt");
291
+ table.string("referenceTokenId").notNullable();
292
+ table.boolean("isUserCreated").notNullable().defaultTo(false);
293
+ table.string("allowedOrigins").notNullable();
294
+
295
+ table.index(["createdBy", "id"], "Session_createdBy_id_key", {
296
+ indexType: "UNIQUE",
297
+ storageEngineIndexType: "btree",
298
+ });
299
+
300
+ table
301
+ .foreign("createdBy")
302
+ .references("User.address")
303
+ .onDelete("cascade")
304
+ .onUpdate("cascade");
305
+ });
306
+ }
307
+
308
+ if (!(await this.operationalStore.schema.hasTable("Challenge"))) {
309
+ await this.operationalStore.schema.createTable("Challenge", (table) => {
310
+ table.string("nonce").primary().notNullable();
311
+ table.string("message").notNullable();
312
+ table.string("signature");
313
+ table.timestamp("createdAt").notNullable().defaultTo(`now()`);
314
+ table.timestamp("updatedAt").notNullable();
315
+
316
+ table.index("nonce", "Challenge_message_key", {
317
+ indexType: "UNIQUE",
318
+ storageEngineIndexType: "btree",
319
+ });
320
+ });
321
+ }
322
+ }
323
+ }
@@ -0,0 +1,39 @@
1
+ import { Context } from "../types";
2
+
3
+ export interface SessionInput {
4
+ name: string;
5
+ allowedOrigins: string[];
6
+ expiresAt?: string;
7
+ }
8
+
9
+ export interface SessionOutput {
10
+ session: Session;
11
+ token: string;
12
+ }
13
+
14
+ export interface Session {
15
+ id: string;
16
+ userId: string;
17
+ address: string;
18
+ name?: string;
19
+ expiresAt: string;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ revokedAt: string | null;
23
+ allowedOrigins: string;
24
+ referenceExpiryDate: string;
25
+ referenceTokenId: string;
26
+ isUserCreated: boolean;
27
+ createdBy: string;
28
+ }
29
+
30
+ export interface Challenge {
31
+ id: string;
32
+ nonce: string;
33
+ signature: string;
34
+ message: string;
35
+ }
36
+
37
+ export type AuthContext = Context & {
38
+ session: Session;
39
+ };
@@ -0,0 +1,136 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-return */
2
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
3
+ /* eslint-disable @typescript-eslint/no-unsafe-call */
4
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
5
+ import { randomUUID } from "crypto";
6
+ import { GraphQLError } from "graphql";
7
+ import jwt from "jsonwebtoken";
8
+ import ms from "ms";
9
+ import wildcard from "wildcard-match";
10
+ import z from "zod";
11
+ import { Session, SessionInput } from "../types";
12
+ import { JWT_EXPIRATION_PERIOD, JWT_SECRET } from "../env";
13
+ import { Db } from "../../../utils/db";
14
+ const jwtSchema = z.object({
15
+ sessionId: z.string(),
16
+ exp: z.optional(z.number()),
17
+ });
18
+
19
+ export const formatToken = (token: string) =>
20
+ `${token.slice(0, 4)}...${token.slice(-4)}`;
21
+
22
+ /** Generate a JWT token
23
+ * - If expiryDurationSeconds is null, the token will never expire
24
+ * - If expiryDurationSeconds is undefined, the token will expire after the default expiry period
25
+ */
26
+ const generateToken = (
27
+ sessionId: string,
28
+ expiryDurationSeconds?: number | null,
29
+ ): string => {
30
+ if (expiryDurationSeconds === null) {
31
+ return jwt.sign({ sessionId }, JWT_SECRET);
32
+ }
33
+
34
+ const expiresIn = expiryDurationSeconds
35
+ ? ms(expiryDurationSeconds * 1000)
36
+ : (JWT_EXPIRATION_PERIOD ?? 3600);
37
+ return jwt.sign({ sessionId }, JWT_SECRET, { expiresIn });
38
+ };
39
+
40
+ const getExpiryDateFromToken = (token: string): Date | null => {
41
+ const { exp } = jwtSchema.parse(jwt.verify(token, JWT_SECRET));
42
+ if (!exp) {
43
+ return null;
44
+ }
45
+ return new Date(exp * 1000);
46
+ };
47
+
48
+ export const verifyToken = (
49
+ token: string,
50
+ ): { sessionId: string } | undefined => {
51
+ const verified = jwt.verify(token, JWT_SECRET, (err, decoded) => {
52
+ if (err) {
53
+ throw new GraphQLError(
54
+ err.name === "TokenExpiredError"
55
+ ? "Token expired"
56
+ : "Invalid authentication token",
57
+ { extensions: { code: "AUTHENTICATION_TOKEN_ERROR" } },
58
+ );
59
+ }
60
+ return decoded;
61
+ }) as { sessionId: string } | undefined;
62
+ if (!verified) {
63
+ return undefined;
64
+ }
65
+ const validated = jwtSchema.parse(verified);
66
+ return validated;
67
+ };
68
+
69
+ function parseOriginMarkup(originParam: string): string {
70
+ if (originParam === "*") {
71
+ return "*";
72
+ }
73
+ const trimmedOriginParam = originParam.trim();
74
+ const origins = trimmedOriginParam.split(",").map((origin) => origin.trim());
75
+ origins.forEach((origin) => {
76
+ if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
77
+ throw new GraphQLError("Origin must start with 'http://' or 'https://'", {
78
+ extensions: { code: "INVALID_ORIGIN_PROTOCOL" },
79
+ });
80
+ }
81
+ });
82
+ return origins.join(",");
83
+ }
84
+
85
+ export function validateOriginAgainstAllowed(
86
+ allowedOrigins: string,
87
+ originReceived?: string,
88
+ ) {
89
+ if (allowedOrigins === "*") {
90
+ return;
91
+ }
92
+ if (!originReceived) {
93
+ throw new GraphQLError("Origin not provided", {
94
+ extensions: { code: "ORIGIN_HEADER_MISSING" },
95
+ });
96
+ }
97
+ const allowedOriginsSplit = allowedOrigins.split(",");
98
+ if (!wildcard(allowedOriginsSplit)(originReceived)) {
99
+ throw new GraphQLError(
100
+ `Access denied due to origin restriction: ${allowedOrigins}, ${originReceived}`,
101
+ {
102
+ extensions: { code: "ORIGIN_FORBIDDEN" },
103
+ },
104
+ );
105
+ }
106
+ }
107
+
108
+ export const generateTokenAndSession = async (
109
+ db: Db,
110
+ session: SessionInput,
111
+ userId: string,
112
+ isUserCreated: boolean,
113
+ ) => {
114
+ const sessionId = randomUUID();
115
+ const generatedToken = generateToken(sessionId, Number(session.expiresAt));
116
+ const referenceExpiryDate = getExpiryDateFromToken(generatedToken);
117
+ const referenceTokenId = formatToken(generatedToken);
118
+ const allowedOrigins = parseOriginMarkup(
119
+ Array.isArray(session.allowedOrigins)
120
+ ? session.allowedOrigins.join(",")
121
+ : session.allowedOrigins,
122
+ );
123
+ const createdSession = await db<Session>("Session").insert({
124
+ id: sessionId,
125
+ name: session.name,
126
+ allowedOrigins,
127
+ referenceExpiryDate: referenceExpiryDate?.toISOString(),
128
+ referenceTokenId,
129
+ isUserCreated: isUserCreated,
130
+ createdBy: userId,
131
+ });
132
+ return {
133
+ token: generatedToken,
134
+ session: createdSession,
135
+ };
136
+ };