Use Fastify Mercurius and GraphQL contracts
Some checks are pending
Build Docker Image / build (push) Waiting to run
Some checks are pending
Build Docker Image / build (push) Waiting to run
This commit is contained in:
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "graphql-contracts"]
|
||||
path = graphql-contracts
|
||||
url = git@gitea.dsrptlab.com:optovia/billing-graphql-contracts.git
|
||||
1
graphql-contracts
Submodule
1
graphql-contracts
Submodule
Submodule graphql-contracts added at 60d70c305d
2662
package-lock.json
generated
2662
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -10,19 +10,17 @@
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.11.3",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"@sentry/node": "^9.5.0",
|
||||
"fastify": "^5.8.5",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jose": "^6.0.11",
|
||||
"tigerbeetle-node": "^0.16.43",
|
||||
"@sentry/node": "^9.5.0"
|
||||
"mercurius": "^16.9.0",
|
||||
"tigerbeetle-node": "^0.16.43"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"prisma": "^6.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
|
||||
112
src/auth.ts
112
src/auth.ts
@@ -1,86 +1,106 @@
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import type { Request } from 'express'
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||
import { GraphQLError } from "graphql";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
|
||||
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
|
||||
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
|
||||
const LOGTO_BILLING_AUDIENCE = process.env.LOGTO_BILLING_AUDIENCE || 'https://billing.optovia.ru'
|
||||
const TEAMS_USER_GRAPHQL_URL = process.env.TEAMS_USER_GRAPHQL_URL || 'https://teams.optovia.ru/graphql/user/'
|
||||
const SESSION_TOKEN_PREFIX = 'optovia-session:'
|
||||
const LOGTO_JWKS_URL =
|
||||
process.env.LOGTO_JWKS_URL || "https://auth.optovia.ru/oidc/jwks";
|
||||
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || "https://auth.optovia.ru/oidc";
|
||||
const LOGTO_BILLING_AUDIENCE =
|
||||
process.env.LOGTO_BILLING_AUDIENCE || "https://billing.optovia.ru";
|
||||
const TEAMS_USER_GRAPHQL_URL =
|
||||
process.env.TEAMS_USER_GRAPHQL_URL ||
|
||||
"https://teams.optovia.ru/graphql/user/";
|
||||
const SESSION_TOKEN_PREFIX = "optovia-session:";
|
||||
|
||||
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
|
||||
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
|
||||
|
||||
export interface AuthContext {
|
||||
userId?: string
|
||||
teamUuid?: string
|
||||
scopes: string[]
|
||||
isM2M?: boolean
|
||||
userId?: string;
|
||||
teamUuid?: string;
|
||||
scopes: string[];
|
||||
isM2M?: boolean;
|
||||
}
|
||||
|
||||
function getBearerToken(req: Request): string {
|
||||
const auth = req.headers.authorization || ''
|
||||
if (!auth.startsWith('Bearer ')) {
|
||||
throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
function getBearerToken(req: FastifyRequest): string {
|
||||
const auth = req.headers.authorization || "";
|
||||
if (!auth.startsWith("Bearer ")) {
|
||||
throw new GraphQLError("Missing Bearer token", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
const token = auth.slice(7)
|
||||
if (!token || token === 'undefined') {
|
||||
throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
const token = auth.slice(7);
|
||||
if (!token || token === "undefined") {
|
||||
throw new GraphQLError("Empty Bearer token", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
return token
|
||||
return token;
|
||||
}
|
||||
|
||||
function scopesFromPayload(payload: JWTPayload): string[] {
|
||||
const scope = payload.scope
|
||||
if (!scope) return []
|
||||
if (typeof scope === 'string') return scope.split(' ')
|
||||
if (Array.isArray(scope)) return scope as string[]
|
||||
return []
|
||||
const scope = payload.scope;
|
||||
if (!scope) return [];
|
||||
if (typeof scope === "string") return scope.split(" ");
|
||||
if (Array.isArray(scope)) return scope as string[];
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function m2mContext(): Promise<AuthContext> {
|
||||
return { scopes: [], isM2M: true }
|
||||
return { scopes: [], isM2M: true };
|
||||
}
|
||||
|
||||
export async function teamContext(req: Request): Promise<AuthContext> {
|
||||
const token = getBearerToken(req)
|
||||
export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
|
||||
const token = getBearerToken(req);
|
||||
if (token.startsWith(SESSION_TOKEN_PREFIX)) {
|
||||
const response = await fetch(TEAMS_USER_GRAPHQL_URL, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `query BillingSessionMe { me { id activeTeamId } }`,
|
||||
}),
|
||||
})
|
||||
const body = await response.json() as { data?: { me?: { id?: string; activeTeamId?: string } } }
|
||||
const me = body.data?.me
|
||||
});
|
||||
const body = (await response.json()) as {
|
||||
data?: { me?: { id?: string; activeTeamId?: string } };
|
||||
};
|
||||
const me = body.data?.me;
|
||||
if (!me?.id || !me.activeTeamId) {
|
||||
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
throw new GraphQLError("Unauthorized", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
return { userId: me.id, teamUuid: me.activeTeamId, scopes: ['teams:member'] }
|
||||
return {
|
||||
userId: me.id,
|
||||
teamUuid: me.activeTeamId,
|
||||
scopes: ["teams:member"],
|
||||
};
|
||||
}
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer: LOGTO_ISSUER,
|
||||
audience: LOGTO_BILLING_AUDIENCE,
|
||||
})
|
||||
});
|
||||
|
||||
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
|
||||
const scopes = scopesFromPayload(payload)
|
||||
const teamUuid = (payload as Record<string, unknown>).team_uuid as
|
||||
| string
|
||||
| undefined;
|
||||
const scopes = scopesFromPayload(payload);
|
||||
|
||||
if (!teamUuid || !scopes.includes('teams:member')) {
|
||||
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
if (!teamUuid || !scopes.includes("teams:member")) {
|
||||
throw new GraphQLError("Unauthorized", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
return { userId: payload.sub, teamUuid, scopes }
|
||||
return { userId: payload.sub, teamUuid, scopes };
|
||||
}
|
||||
|
||||
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
||||
const missing = required.filter(s => !ctx.scopes.includes(s))
|
||||
const missing = required.filter((s) => !ctx.scopes.includes(s));
|
||||
if (missing.length > 0) {
|
||||
throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, {
|
||||
extensions: { code: "FORBIDDEN" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
120
src/index.ts
120
src/index.ts
@@ -1,64 +1,82 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { ApolloServer } from '@apollo/server'
|
||||
import { expressMiddleware } from '@apollo/server/express4'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { teamTypeDefs, teamResolvers } from './schemas/team.js'
|
||||
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
|
||||
import { m2mContext, teamContext, type AuthContext } from './auth.js'
|
||||
import Fastify from "fastify";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import mercurius from "mercurius";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { teamTypeDefs, teamResolvers } from "./schemas/team.js";
|
||||
import { m2mTypeDefs, m2mResolvers } from "./schemas/m2m.js";
|
||||
import { m2mContext, teamContext } from "./auth.js";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8000', 10)
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN || ''
|
||||
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN || "";
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
tracesSampleRate: 0.01,
|
||||
release: process.env.RELEASE_VERSION || '1.0.0',
|
||||
environment: process.env.ENVIRONMENT || 'production',
|
||||
})
|
||||
release: process.env.RELEASE_VERSION || "1.0.0",
|
||||
environment: process.env.ENVIRONMENT || "production",
|
||||
});
|
||||
}
|
||||
|
||||
const app = express()
|
||||
const app = Fastify();
|
||||
await app.register(cors, { origin: ["https://optovia.ru"], credentials: true });
|
||||
|
||||
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
|
||||
type GraphqlBody = {
|
||||
query?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
operationName?: string;
|
||||
};
|
||||
|
||||
const teamServer = new ApolloServer<AuthContext>({
|
||||
typeDefs: teamTypeDefs,
|
||||
resolvers: teamResolvers,
|
||||
introspection: true,
|
||||
})
|
||||
async function registerGraphqlEndpoint(
|
||||
server: FastifyInstance,
|
||||
path: string,
|
||||
schema: string,
|
||||
resolvers: unknown,
|
||||
context: (request: FastifyRequest) => Promise<unknown> | unknown,
|
||||
) {
|
||||
await server.register(
|
||||
async (route) => {
|
||||
await route.register(mercurius, {
|
||||
schema,
|
||||
resolvers: resolvers as never,
|
||||
routes: false,
|
||||
});
|
||||
route.post("/", async (request, reply) => {
|
||||
const body = request.body as GraphqlBody;
|
||||
if (typeof body.query !== "string") {
|
||||
throw new Error("GraphQL query is required");
|
||||
}
|
||||
return reply.graphql(
|
||||
body.query,
|
||||
(await context(request)) as Record<string, unknown>,
|
||||
body.variables,
|
||||
body.operationName,
|
||||
);
|
||||
});
|
||||
},
|
||||
{ prefix: path },
|
||||
);
|
||||
}
|
||||
|
||||
const m2mServer = new ApolloServer<AuthContext>({
|
||||
typeDefs: m2mTypeDefs,
|
||||
resolvers: m2mResolvers,
|
||||
introspection: true,
|
||||
})
|
||||
await registerGraphqlEndpoint(
|
||||
app,
|
||||
"/graphql/team",
|
||||
teamTypeDefs,
|
||||
teamResolvers,
|
||||
teamContext,
|
||||
);
|
||||
await registerGraphqlEndpoint(
|
||||
app,
|
||||
"/graphql/m2m",
|
||||
m2mTypeDefs,
|
||||
m2mResolvers,
|
||||
async () => m2mContext(),
|
||||
);
|
||||
|
||||
await Promise.all([teamServer.start(), m2mServer.start()])
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
|
||||
app.use(
|
||||
'/graphql/team',
|
||||
express.json(),
|
||||
expressMiddleware(teamServer, {
|
||||
context: async ({ req }) => teamContext(req as unknown as import('express').Request),
|
||||
}) as unknown as express.RequestHandler,
|
||||
)
|
||||
|
||||
app.use(
|
||||
'/graphql/m2m',
|
||||
express.json(),
|
||||
expressMiddleware(m2mServer, {
|
||||
context: async () => m2mContext(),
|
||||
}) as unknown as express.RequestHandler,
|
||||
)
|
||||
|
||||
app.get('/health', (_, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Billing server ready on port ${PORT}`)
|
||||
console.log(` /graphql/team - team access token auth`)
|
||||
console.log(` /graphql/m2m - internal services (no auth)`)
|
||||
})
|
||||
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||
console.log(`Billing server ready on port ${PORT}`);
|
||||
console.log(` /graphql/team - team access token auth`);
|
||||
console.log(` /graphql/m2m - internal services (no auth)`);
|
||||
|
||||
Reference in New Issue
Block a user