Compare commits
5 Commits
e028f7c038
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ecf082643 | ||
|
|
7eb1742d6a | ||
|
|
e5cbb8855d | ||
|
|
6719e9faf7 | ||
|
|
3392bedc80 |
@@ -1,4 +1,4 @@
|
|||||||
name: Build Docker Image
|
name: Build and deploy Docker image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,13 +6,12 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: builder
|
||||||
|
env:
|
||||||
|
IMAGE: gitea.dsrptlab.com/optovia/orders/orders
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -20,12 +19,11 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
run: |
|
||||||
with:
|
docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" .
|
||||||
context: .
|
docker push "$IMAGE:latest"
|
||||||
push: true
|
docker push "$IMAGE:${{ gitea.sha }}"
|
||||||
tags: gitea.dsrptlab.com/optovia/orders/orders:latest
|
|
||||||
|
|
||||||
- name: Deploy to Dokploy
|
- name: Deploy to Dokploy
|
||||||
run: curl -k -X POST "https://dokploy.dsrptlab.com/api/deploy/VZukUOjYSYb1XmXbM5jIV"
|
run: curl -fsS -X POST "https://ind.dsrptlab.com/api/deploy/8KU24cMi_nHB4S16dhDB3"
|
||||||
|
|||||||
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/orders-graphql-contracts.git
|
||||||
1
graphql-contracts
Submodule
1
graphql-contracts
Submodule
Submodule graphql-contracts added at 88e1aa58bf
2664
package-lock.json
generated
2664
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -9,18 +9,16 @@
|
|||||||
"start": "prisma migrate deploy && node dist/index.js"
|
"start": "prisma migrate deploy && node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^4.11.3",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@prisma/client": "^6.5.0",
|
"@prisma/client": "^6.5.0",
|
||||||
"cors": "^2.8.5",
|
"@sentry/node": "^9.5.0",
|
||||||
"express": "^4.21.2",
|
"fastify": "^5.8.5",
|
||||||
"graphql": "^16.10.0",
|
"graphql": "^16.10.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
"jose": "^6.0.11",
|
"jose": "^6.0.11",
|
||||||
"@sentry/node": "^9.5.0"
|
"mercurius": "^16.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/node": "^22.13.0",
|
"@types/node": "^22.13.0",
|
||||||
"prisma": "^6.5.0",
|
"prisma": "^6.5.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
|
|||||||
125
src/auth.ts
125
src/auth.ts
@@ -1,78 +1,125 @@
|
|||||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from "graphql";
|
||||||
import type { Request } from 'express'
|
import type { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
|
const LOGTO_JWKS_URL =
|
||||||
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
|
process.env.LOGTO_JWKS_URL || "https://auth.optovia.ru/oidc/jwks";
|
||||||
const LOGTO_ORDERS_AUDIENCE = process.env.LOGTO_ORDERS_AUDIENCE || 'https://orders.optovia.ru'
|
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || "https://auth.optovia.ru/oidc";
|
||||||
|
const LOGTO_ORDERS_AUDIENCE =
|
||||||
|
process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru";
|
||||||
|
|
||||||
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
|
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
|
||||||
|
|
||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
userId?: string
|
userId?: string;
|
||||||
teamUuid?: string
|
teamUuid?: string;
|
||||||
scopes: string[]
|
scopes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBearerToken(req: Request): string {
|
function getBearerToken(req: FastifyRequest): string {
|
||||||
const auth = req.headers.authorization || ''
|
const auth = req.headers.authorization || "";
|
||||||
if (!auth.startsWith('Bearer ')) {
|
if (!auth.startsWith("Bearer ")) {
|
||||||
throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError("Missing Bearer token", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const token = auth.slice(7)
|
const token = auth.slice(7);
|
||||||
if (!token || token === 'undefined') {
|
if (!token || token === "undefined") {
|
||||||
throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError("Empty Bearer token", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return token
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopesFromPayload(payload: JWTPayload): string[] {
|
function scopesFromPayload(payload: JWTPayload): string[] {
|
||||||
const scope = payload.scope
|
const scope = payload.scope;
|
||||||
if (!scope) return []
|
if (!scope) return [];
|
||||||
if (typeof scope === 'string') return scope.split(' ')
|
if (typeof scope === "string") return scope.split(" ");
|
||||||
if (Array.isArray(scope)) return scope as string[]
|
if (Array.isArray(scope)) return scope as string[];
|
||||||
return []
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimList(payload: JWTPayload, key: string): string[] {
|
||||||
|
const value = (payload as Record<string, unknown>)[key];
|
||||||
|
if (typeof value === "string") return value.split(" ");
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return value.filter((item): item is string => typeof item === "string");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasManagerClaim(payload: JWTPayload): boolean {
|
||||||
|
const scopes = scopesFromPayload(payload);
|
||||||
|
return (
|
||||||
|
scopes.includes("manager") ||
|
||||||
|
claimList(payload, "roles").includes("manager") ||
|
||||||
|
claimList(payload, "permissions").includes("manager")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publicContext(): Promise<AuthContext> {
|
export async function publicContext(): Promise<AuthContext> {
|
||||||
return { scopes: [] }
|
return { scopes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function userContext(req: Request): Promise<AuthContext> {
|
export async function userContext(req: FastifyRequest): Promise<AuthContext> {
|
||||||
const token = getBearerToken(req)
|
const token = getBearerToken(req);
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
|
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER });
|
||||||
return {
|
return {
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
scopes: scopesFromPayload(payload),
|
scopes: scopesFromPayload(payload),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function teamContext(req: Request): Promise<AuthContext> {
|
export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
|
||||||
const token = getBearerToken(req)
|
const token = getBearerToken(req);
|
||||||
const { payload } = await jwtVerify(token, jwks, {
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
issuer: LOGTO_ISSUER,
|
issuer: LOGTO_ISSUER,
|
||||||
audience: LOGTO_ORDERS_AUDIENCE,
|
audience: LOGTO_ORDERS_AUDIENCE,
|
||||||
})
|
});
|
||||||
|
|
||||||
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
|
const teamUuid = (payload as Record<string, unknown>).team_uuid as
|
||||||
const scopes = scopesFromPayload(payload)
|
| string
|
||||||
|
| undefined;
|
||||||
|
const scopes = scopesFromPayload(payload);
|
||||||
|
|
||||||
if (!teamUuid || !scopes.includes('teams:member')) {
|
if (!teamUuid || !scopes.includes("teams:member")) {
|
||||||
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError("Unauthorized", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
teamUuid,
|
teamUuid,
|
||||||
scopes,
|
scopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function managerContext(
|
||||||
|
req: FastifyRequest,
|
||||||
|
): Promise<AuthContext> {
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
|
issuer: LOGTO_ISSUER,
|
||||||
|
audience: LOGTO_ORDERS_AUDIENCE,
|
||||||
|
});
|
||||||
|
const scopes = scopesFromPayload(payload);
|
||||||
|
const teamUuid = (payload as Record<string, unknown>).team_uuid as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
if (!payload.sub || !hasManagerClaim(payload) || !teamUuid) {
|
||||||
|
throw new GraphQLError("Unauthorized", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return { userId: payload.sub, teamUuid, scopes: ["teams:member", "manager"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
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) {
|
if (missing.length > 0) {
|
||||||
throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, {
|
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, {
|
||||||
extensions: { code: 'FORBIDDEN' },
|
extensions: { code: "FORBIDDEN" },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
src/index.ts
162
src/index.ts
@@ -1,86 +1,104 @@
|
|||||||
import express from 'express'
|
import Fastify from "fastify";
|
||||||
import cors from 'cors'
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
import { ApolloServer } from '@apollo/server'
|
import cors from "@fastify/cors";
|
||||||
import { expressMiddleware } from '@apollo/server/express4'
|
import mercurius from "mercurius";
|
||||||
import * as Sentry from '@sentry/node'
|
import * as Sentry from "@sentry/node";
|
||||||
import { publicTypeDefs, publicResolvers } from './schemas/public.js'
|
import { publicTypeDefs, publicResolvers } from "./schemas/public.js";
|
||||||
import { userTypeDefs, userResolvers } from './schemas/user.js'
|
import { userTypeDefs, userResolvers } from "./schemas/user.js";
|
||||||
import { teamTypeDefs, teamResolvers } from './schemas/team.js'
|
import { teamTypeDefs, teamResolvers } from "./schemas/team.js";
|
||||||
import { publicContext, userContext, teamContext, type AuthContext } from './auth.js'
|
import {
|
||||||
|
publicContext,
|
||||||
|
userContext,
|
||||||
|
teamContext,
|
||||||
|
managerContext,
|
||||||
|
} from "./auth.js";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '8000', 10)
|
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
|
||||||
const SENTRY_DSN = process.env.SENTRY_DSN || ''
|
const SENTRY_DSN = process.env.SENTRY_DSN || "";
|
||||||
|
|
||||||
if (SENTRY_DSN) {
|
if (SENTRY_DSN) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: SENTRY_DSN,
|
dsn: SENTRY_DSN,
|
||||||
tracesSampleRate: 0.01,
|
tracesSampleRate: 0.01,
|
||||||
release: process.env.RELEASE_VERSION || '1.0.0',
|
release: process.env.RELEASE_VERSION || "1.0.0",
|
||||||
environment: process.env.ENVIRONMENT || 'production',
|
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 publicServer = new ApolloServer<AuthContext>({
|
async function registerGraphqlEndpoint(
|
||||||
typeDefs: publicTypeDefs,
|
server: FastifyInstance,
|
||||||
resolvers: publicResolvers,
|
path: string,
|
||||||
introspection: true,
|
schema: string,
|
||||||
})
|
resolvers: unknown,
|
||||||
|
context: (request: FastifyRequest) => Promise<unknown> | unknown,
|
||||||
const userServer = new ApolloServer<AuthContext>({
|
) {
|
||||||
typeDefs: userTypeDefs,
|
await server.register(
|
||||||
resolvers: userResolvers,
|
async (route) => {
|
||||||
introspection: true,
|
await route.register(mercurius, {
|
||||||
})
|
schema,
|
||||||
|
resolvers: resolvers as never,
|
||||||
const teamServer = new ApolloServer<AuthContext>({
|
routes: false,
|
||||||
typeDefs: teamTypeDefs,
|
});
|
||||||
resolvers: teamResolvers,
|
route.post("/", async (request, reply) => {
|
||||||
introspection: true,
|
const body = request.body as GraphqlBody;
|
||||||
})
|
if (typeof body.query !== "string") {
|
||||||
|
throw new Error("GraphQL query is required");
|
||||||
await Promise.all([publicServer.start(), userServer.start(), teamServer.start()])
|
}
|
||||||
|
return reply.graphql(
|
||||||
app.use(
|
body.query,
|
||||||
'/graphql/public',
|
(await context(request)) as Record<string, unknown>,
|
||||||
express.json(),
|
body.variables,
|
||||||
expressMiddleware(publicServer, {
|
body.operationName,
|
||||||
context: async () => publicContext(),
|
);
|
||||||
}) as unknown as express.RequestHandler,
|
});
|
||||||
)
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
'/graphql/user',
|
|
||||||
express.json(),
|
|
||||||
expressMiddleware(userServer, {
|
|
||||||
context: async ({ req }) => {
|
|
||||||
try {
|
|
||||||
return await userContext(req as unknown as import('express').Request)
|
|
||||||
} catch {
|
|
||||||
return { scopes: [] }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}) as unknown as express.RequestHandler,
|
{ prefix: path },
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.use(
|
await registerGraphqlEndpoint(
|
||||||
'/graphql/team',
|
app,
|
||||||
express.json(),
|
"/graphql/public",
|
||||||
expressMiddleware(teamServer, {
|
publicTypeDefs,
|
||||||
context: async ({ req }) => teamContext(req as unknown as import('express').Request),
|
publicResolvers,
|
||||||
}) as unknown as express.RequestHandler,
|
async () => publicContext(),
|
||||||
)
|
);
|
||||||
|
await registerGraphqlEndpoint(
|
||||||
|
app,
|
||||||
|
"/graphql/user",
|
||||||
|
userTypeDefs,
|
||||||
|
userResolvers,
|
||||||
|
userContext,
|
||||||
|
);
|
||||||
|
await registerGraphqlEndpoint(
|
||||||
|
app,
|
||||||
|
"/graphql/team",
|
||||||
|
teamTypeDefs,
|
||||||
|
teamResolvers,
|
||||||
|
teamContext,
|
||||||
|
);
|
||||||
|
await registerGraphqlEndpoint(
|
||||||
|
app,
|
||||||
|
"/graphql/manager",
|
||||||
|
teamTypeDefs,
|
||||||
|
teamResolvers,
|
||||||
|
managerContext,
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/health', (_, res) => {
|
app.get("/health", async () => ({ status: "ok" }));
|
||||||
res.json({ status: 'ok' })
|
|
||||||
})
|
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
console.log(`Orders server ready on port ${PORT}`)
|
console.log(`Orders server ready on port ${PORT}`);
|
||||||
console.log(` /graphql/public - public`)
|
console.log(` /graphql/public - public`);
|
||||||
console.log(` /graphql/user - id token auth`)
|
console.log(` /graphql/user - id token auth`);
|
||||||
console.log(` /graphql/team - team access token auth`)
|
console.log(` /graphql/team - team access token auth`);
|
||||||
})
|
console.log(` /graphql/manager - manager JWT auth`);
|
||||||
|
|||||||
Reference in New Issue
Block a user