From 293ab34cec35e5566bf2ba4c4c2beb0f8d3393c7 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sat, 27 Dec 2025 14:20:43 -0300 Subject: [PATCH] refactor identity-gateway to fastify service --- identity-gateway/.env.example | 10 +++ identity-gateway/Dockerfile | 21 +++++ identity-gateway/README.md | 60 ++++++++++++++ identity-gateway/docker-compose.yml | 25 ++++++ identity-gateway/docs/architecture.md | 31 ++++++++ identity-gateway/docs/security.md | 23 ++++++ identity-gateway/docs/token-model.md | 21 +++++ identity-gateway/go.mod | 8 -- identity-gateway/internal/auth/provider.go | 64 --------------- identity-gateway/migrations/migration.sql | 69 ++++++++++++---- identity-gateway/package.json | 32 ++++++++ identity-gateway/src/core/auth.guard.ts | 20 +++++ identity-gateway/src/core/permission.guard.ts | 15 ++++ identity-gateway/src/core/rbac.guard.ts | 13 +++ identity-gateway/src/core/tenant.context.ts | 21 +++++ identity-gateway/src/core/token.service.ts | 40 ++++++++++ identity-gateway/src/lib/crypto.ts | 11 +++ identity-gateway/src/lib/db.ts | 10 +++ identity-gateway/src/lib/env.ts | 29 +++++++ identity-gateway/src/lib/logger.ts | 13 +++ identity-gateway/src/main.ts | 43 ++++++++++ .../src/modules/auth/auth.controller.ts | 62 +++++++++++++++ .../src/modules/auth/auth.service.ts | 78 ++++++++++++++++++ .../modules/permissions/permission.entity.ts | 5 ++ .../modules/permissions/permission.service.ts | 19 +++++ .../modules/providers/external.provider.ts | 12 +++ .../src/modules/providers/local.provider.ts | 25 ++++++ .../modules/providers/provider.interface.ts | 14 ++++ .../src/modules/roles/role.entity.ts | 5 ++ .../src/modules/roles/role.service.ts | 19 +++++ .../src/modules/sessions/session.entity.ts | 9 +++ .../src/modules/sessions/session.service.ts | 40 ++++++++++ .../src/modules/users/user.controller.ts | 25 ++++++ .../src/modules/users/user.entity.ts | 7 ++ .../src/modules/users/user.service.ts | 79 +++++++++++++++++++ identity-gateway/tsconfig.json | 14 ++++ 36 files changed, 906 insertions(+), 86 deletions(-) create mode 100644 identity-gateway/.env.example create mode 100644 identity-gateway/Dockerfile create mode 100644 identity-gateway/README.md create mode 100644 identity-gateway/docker-compose.yml create mode 100644 identity-gateway/docs/architecture.md create mode 100644 identity-gateway/docs/security.md create mode 100644 identity-gateway/docs/token-model.md delete mode 100644 identity-gateway/go.mod delete mode 100644 identity-gateway/internal/auth/provider.go create mode 100644 identity-gateway/package.json create mode 100644 identity-gateway/src/core/auth.guard.ts create mode 100644 identity-gateway/src/core/permission.guard.ts create mode 100644 identity-gateway/src/core/rbac.guard.ts create mode 100644 identity-gateway/src/core/tenant.context.ts create mode 100644 identity-gateway/src/core/token.service.ts create mode 100644 identity-gateway/src/lib/crypto.ts create mode 100644 identity-gateway/src/lib/db.ts create mode 100644 identity-gateway/src/lib/env.ts create mode 100644 identity-gateway/src/lib/logger.ts create mode 100644 identity-gateway/src/main.ts create mode 100644 identity-gateway/src/modules/auth/auth.controller.ts create mode 100644 identity-gateway/src/modules/auth/auth.service.ts create mode 100644 identity-gateway/src/modules/permissions/permission.entity.ts create mode 100644 identity-gateway/src/modules/permissions/permission.service.ts create mode 100644 identity-gateway/src/modules/providers/external.provider.ts create mode 100644 identity-gateway/src/modules/providers/local.provider.ts create mode 100644 identity-gateway/src/modules/providers/provider.interface.ts create mode 100644 identity-gateway/src/modules/roles/role.entity.ts create mode 100644 identity-gateway/src/modules/roles/role.service.ts create mode 100644 identity-gateway/src/modules/sessions/session.entity.ts create mode 100644 identity-gateway/src/modules/sessions/session.service.ts create mode 100644 identity-gateway/src/modules/users/user.controller.ts create mode 100644 identity-gateway/src/modules/users/user.entity.ts create mode 100644 identity-gateway/src/modules/users/user.service.ts create mode 100644 identity-gateway/tsconfig.json diff --git a/identity-gateway/.env.example b/identity-gateway/.env.example new file mode 100644 index 0000000..9a41552 --- /dev/null +++ b/identity-gateway/.env.example @@ -0,0 +1,10 @@ +NODE_ENV=development +PORT=4000 +LOG_LEVEL=info +DATABASE_URL=postgres://identity:identity@localhost:5432/identity_gateway +JWT_ACCESS_SECRET=replace_me_access +JWT_REFRESH_SECRET=replace_me_refresh +JWT_ACCESS_TTL=15m +JWT_REFRESH_TTL=30d +COOKIE_DOMAIN=localhost +COOKIE_SECURE=false diff --git a/identity-gateway/Dockerfile b/identity-gateway/Dockerfile new file mode 100644 index 0000000..f45472a --- /dev/null +++ b/identity-gateway/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS base + +WORKDIR /app + +COPY package.json tsconfig.json ./ +RUN npm install + +COPY src ./src + +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=base /app/package.json ./package.json +COPY --from=base /app/node_modules ./node_modules +COPY --from=base /app/dist ./dist + +EXPOSE 4000 +CMD ["node", "dist/main.js"] diff --git a/identity-gateway/README.md b/identity-gateway/README.md new file mode 100644 index 0000000..58d2c28 --- /dev/null +++ b/identity-gateway/README.md @@ -0,0 +1,60 @@ +# identity-gateway + +`identity-gateway` é a autoridade de identidade **interna** da plataforma. Ele existe para unificar +identidade, RBAC e emissão de tokens confiáveis para serviços internos. **Não é** um produto de auth +para o mercado, não compete com Auth0/Clerk e não oferece SDKs públicos ou UI de login white-label. + +## Por que NÃO é Auth0 + +- Não é vendido como produto standalone de autenticação. +- Não é SDK-first e não prioriza experiência de dev externo. +- Tokens são internos e consumidos apenas por serviços confiáveis. +- UI de login não é foco (nem fornecida aqui). + +## Papel do identity-gateway + +- Centralizar autenticação e autorização. +- Emitir JWTs para serviços internos. +- Manter RBAC e permissões por tenant. +- Ser a autoridade de identidade para: + - `baas-control-plane` + - `billing-finance-core` + - `crm-core` + +## Fluxo de confiança + +1. Usuário autentica no `identity-gateway`. +2. O gateway valida a identidade (provider local/externo). +3. O gateway emite JWT interno com claims mínimas. +4. Serviços internos validam e confiam no token. + +> Nenhum serviço externo emite tokens para o gateway. + +## Modelo de tokens + +Veja [`docs/token-model.md`](docs/token-model.md). + +## Rodando localmente + +```bash +cp .env.example .env +npm install +npm run dev +``` + +## Docker + +```bash +docker-compose up --build +``` + +## Estrutura + +- `src/core`: guards e serviços centrais. +- `src/modules`: auth, users, roles, permissions, sessions, providers. +- `docs`: arquitetura, segurança e modelo de tokens. + +## Notas de segurança + +- JWTs são internos e não devem ser expostos diretamente a apps públicos. +- Refresh tokens são armazenados com hash no banco. diff --git a/identity-gateway/docker-compose.yml b/identity-gateway/docker-compose.yml new file mode 100644 index 0000000..356294a --- /dev/null +++ b/identity-gateway/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3.9" + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: identity + POSTGRES_PASSWORD: identity + POSTGRES_DB: identity_gateway + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + identity-gateway: + build: . + env_file: + - .env + ports: + - "4000:4000" + depends_on: + - postgres + +volumes: + pgdata: diff --git a/identity-gateway/docs/architecture.md b/identity-gateway/docs/architecture.md new file mode 100644 index 0000000..4fc4cb5 --- /dev/null +++ b/identity-gateway/docs/architecture.md @@ -0,0 +1,31 @@ +# Architecture + +`identity-gateway` is an internal authority for identity across the SaaS platform. It sits between +human users and internal services, issuing trusted JWTs for service-to-service access. + +## Core responsibilities + +- Central authentication and authorization. +- RBAC and permission enforcement per tenant. +- Token issuance for trusted backend services. +- Provider-agnostic identity validation (local/external). + +## Components + +- **Auth module**: Handles login, refresh, and logout flows. +- **Users module**: Maintains internal user identities and tenant membership. +- **Roles & Permissions**: Defines RBAC primitives and tenant-specific grants. +- **Sessions**: Stores refresh token sessions. +- **Core guards**: Enforces authentication, roles, and permissions. + +## Trust boundaries + +- Only internal services validate JWTs issued by the gateway. +- JWTs are not intended for public client apps without a proxy. + +## Data flow + +1. User authenticates with `identity-gateway`. +2. Gateway validates identity via provider and maps user to tenant. +3. Gateway issues access + refresh tokens. +4. Internal services validate access token claims. diff --git a/identity-gateway/docs/security.md b/identity-gateway/docs/security.md new file mode 100644 index 0000000..0e7a109 --- /dev/null +++ b/identity-gateway/docs/security.md @@ -0,0 +1,23 @@ +# Security Model + +## Principles + +- Tokens are internal-only and never exposed to untrusted clients directly. +- Permissions are centrally managed in the gateway. +- Providers only validate identity; they do not set sessions or permissions. + +## JWTs + +- Access tokens are short-lived and contain minimal claims. +- Refresh tokens are stored hashed and revocable. + +## Multi-tenant isolation + +- User membership is scoped by tenant. +- Roles and permissions are evaluated per tenant. + +## Operational safeguards + +- Rotate JWT secrets regularly. +- Use TLS in production. +- Enable HTTP-only cookies for refresh tokens when needed. diff --git a/identity-gateway/docs/token-model.md b/identity-gateway/docs/token-model.md new file mode 100644 index 0000000..6f5246a --- /dev/null +++ b/identity-gateway/docs/token-model.md @@ -0,0 +1,21 @@ +# Token Model + +## Access token claims + +```json +{ + "userId": "uuid", + "tenantId": "uuid", + "roles": ["admin"], + "permissions": ["billing.read", "baas.write"] +} +``` + +- Access tokens are used by internal services only. +- TTL is short (default 15 minutes). + +## Refresh tokens + +- Stored hashed in the database. +- Used to issue new access tokens. +- Revoked on logout or compromise. diff --git a/identity-gateway/go.mod b/identity-gateway/go.mod deleted file mode 100644 index 4d952ed..0000000 --- a/identity-gateway/go.mod +++ /dev/null @@ -1,8 +0,0 @@ -module identity-gateway - -go 1.22 - -require ( - github.com/appwrite/sdk-for-go v1.0.4 - github.com/gin-gonic/gin v1.10.0 -) diff --git a/identity-gateway/internal/auth/provider.go b/identity-gateway/internal/auth/provider.go deleted file mode 100644 index 8408de3..0000000 --- a/identity-gateway/internal/auth/provider.go +++ /dev/null @@ -1,64 +0,0 @@ -package auth - -import ( - "database/sql" - "net/http" - "strings" - - "github.com/appwrite/sdk-for-go/client" - "github.com/appwrite/sdk-for-go/users" - "github.com/gin-gonic/gin" -) - -type MyUser struct { - ID string - Role string -} - -type AuthProvider struct { - AppwriteClient client.Client - DB *sql.DB -} - -func (p *AuthProvider) ValidateSession() gin.HandlerFunc { - return func(c *gin.Context) { - tokenHeader := c.GetHeader("Authorization") - token := strings.TrimSpace(strings.TrimPrefix(tokenHeader, "Bearer")) - if token == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token ausente"}) - return - } - - p.AppwriteClient.SetJWT(token) - appwriteUsers := users.New(p.AppwriteClient) - remoteUser, err := appwriteUsers.Get() - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Sessão inválida no IDP"}) - return - } - - var userLocal MyUser - err = p.DB.QueryRow( - "SELECT id, role FROM users WHERE appwrite_id = $1", - remoteUser.Id, - ).Scan(&userLocal.ID, &userLocal.Role) - - if err == sql.ErrNoRows { - err = p.DB.QueryRow( - "INSERT INTO users (appwrite_id, email, full_name) VALUES ($1, $2, $3) RETURNING id, role", - remoteUser.Id, - remoteUser.Email, - remoteUser.Name, - ).Scan(&userLocal.ID, &userLocal.Role) - } - - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Erro ao sincronizar usuário"}) - return - } - - c.Set("user_id", userLocal.ID) - c.Set("user_role", userLocal.Role) - c.Next() - } -} diff --git a/identity-gateway/migrations/migration.sql b/identity-gateway/migrations/migration.sql index c26549c..af57b5f 100644 --- a/identity-gateway/migrations/migration.sql +++ b/identity-gateway/migrations/migration.sql @@ -1,17 +1,58 @@ -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - appwrite_id VARCHAR(255) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - full_name VARCHAR(255), - role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'manager', 'user')), - is_active BOOLEAN DEFAULT true, - last_login TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE user_security_configs ( - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - master_key_hint VARCHAR(100), - encryption_version INT DEFAULT 1, - PRIMARY KEY (user_id) +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + identifier TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_tenants ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, tenant_id) +); + +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL UNIQUE, + description TEXT +); + +CREATE TABLE IF NOT EXISTS permissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL UNIQUE, + description TEXT +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, tenant_id, role_id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + refresh_token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ ); diff --git a/identity-gateway/package.json b/identity-gateway/package.json new file mode 100644 index 0000000..4051673 --- /dev/null +++ b/identity-gateway/package.json @@ -0,0 +1,32 @@ +{ + "name": "identity-gateway", + "version": "0.1.0", + "private": true, + "description": "Internal identity gateway for multi-service SaaS platforms", + "type": "commonjs", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/main.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js", + "lint": "eslint . --ext .ts" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "dotenv": "^16.4.5", + "fastify": "^4.28.1", + "fastify-cookie": "^5.7.0", + "jsonwebtoken": "^9.0.2", + "pg": "^8.12.0", + "pino": "^9.3.2" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.10", + "@types/pg": "^8.11.6", + "eslint": "^9.6.0", + "pino-pretty": "^11.2.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.5.3" + } +} diff --git a/identity-gateway/src/core/auth.guard.ts b/identity-gateway/src/core/auth.guard.ts new file mode 100644 index 0000000..927a66f --- /dev/null +++ b/identity-gateway/src/core/auth.guard.ts @@ -0,0 +1,20 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { TokenService } from "./token.service"; + +export const authGuard = (tokenService: TokenService) => { + return async (request: FastifyRequest, reply: FastifyReply) => { + const header = request.headers.authorization; + if (!header?.startsWith("Bearer ")) { + reply.code(401).send({ message: "Missing access token" }); + return; + } + + const token = header.replace("Bearer ", ""); + try { + request.tenantContext = tokenService.verifyAccessToken(token); + } catch (error) { + reply.code(401).send({ message: "Invalid access token" }); + return; + } + }; +}; diff --git a/identity-gateway/src/core/permission.guard.ts b/identity-gateway/src/core/permission.guard.ts new file mode 100644 index 0000000..96ff957 --- /dev/null +++ b/identity-gateway/src/core/permission.guard.ts @@ -0,0 +1,15 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { getTenantContext } from "./tenant.context"; + +export const permissionGuard = (permissions: string[]) => { + return async (request: FastifyRequest, reply: FastifyReply) => { + const context = getTenantContext(request); + const hasPermission = permissions.some((permission) => + context.permissions.includes(permission) + ); + if (!hasPermission) { + reply.code(403).send({ message: "Missing permission" }); + return; + } + }; +}; diff --git a/identity-gateway/src/core/rbac.guard.ts b/identity-gateway/src/core/rbac.guard.ts new file mode 100644 index 0000000..14d054d --- /dev/null +++ b/identity-gateway/src/core/rbac.guard.ts @@ -0,0 +1,13 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { getTenantContext } from "./tenant.context"; + +export const rbacGuard = (roles: string[]) => { + return async (request: FastifyRequest, reply: FastifyReply) => { + const context = getTenantContext(request); + const hasRole = roles.some((role) => context.roles.includes(role)); + if (!hasRole) { + reply.code(403).send({ message: "Insufficient role" }); + return; + } + }; +}; diff --git a/identity-gateway/src/core/tenant.context.ts b/identity-gateway/src/core/tenant.context.ts new file mode 100644 index 0000000..9f3cb12 --- /dev/null +++ b/identity-gateway/src/core/tenant.context.ts @@ -0,0 +1,21 @@ +import { FastifyRequest } from "fastify"; + +export interface TenantContext { + userId: string; + tenantId: string; + roles: string[]; + permissions: string[]; +} + +declare module "fastify" { + interface FastifyRequest { + tenantContext?: TenantContext; + } +} + +export const getTenantContext = (request: FastifyRequest) => { + if (!request.tenantContext) { + throw new Error("Tenant context not initialized"); + } + return request.tenantContext; +}; diff --git a/identity-gateway/src/core/token.service.ts b/identity-gateway/src/core/token.service.ts new file mode 100644 index 0000000..8f24c36 --- /dev/null +++ b/identity-gateway/src/core/token.service.ts @@ -0,0 +1,40 @@ +import jwt from "jsonwebtoken"; +import { env } from "../lib/env"; +import { TenantContext } from "./tenant.context"; + +export interface TokenPair { + accessToken: string; + refreshToken: string; +} + +export class TokenService { + signAccessToken(payload: TenantContext) { + return jwt.sign(payload, env.jwtAccessSecret, { + expiresIn: env.jwtAccessTtl, + }); + } + + signRefreshToken(payload: TenantContext) { + return jwt.sign( + { + userId: payload.userId, + tenantId: payload.tenantId, + }, + env.jwtRefreshSecret, + { + expiresIn: env.jwtRefreshTtl, + } + ); + } + + verifyAccessToken(token: string) { + return jwt.verify(token, env.jwtAccessSecret) as TenantContext; + } + + verifyRefreshToken(token: string) { + return jwt.verify(token, env.jwtRefreshSecret) as { + userId: string; + tenantId: string; + }; + } +} diff --git a/identity-gateway/src/lib/crypto.ts b/identity-gateway/src/lib/crypto.ts new file mode 100644 index 0000000..596451a --- /dev/null +++ b/identity-gateway/src/lib/crypto.ts @@ -0,0 +1,11 @@ +import bcrypt from "bcryptjs"; + +const SALT_ROUNDS = 12; + +export const hashSecret = async (value: string) => { + return bcrypt.hash(value, SALT_ROUNDS); +}; + +export const verifySecret = async (value: string, hash: string) => { + return bcrypt.compare(value, hash); +}; diff --git a/identity-gateway/src/lib/db.ts b/identity-gateway/src/lib/db.ts new file mode 100644 index 0000000..f6499dd --- /dev/null +++ b/identity-gateway/src/lib/db.ts @@ -0,0 +1,10 @@ +import { Pool } from "pg"; +import { env } from "./env"; + +export const db = new Pool({ + connectionString: env.databaseUrl, +}); + +export const closeDb = async () => { + await db.end(); +}; diff --git a/identity-gateway/src/lib/env.ts b/identity-gateway/src/lib/env.ts new file mode 100644 index 0000000..3e2f499 --- /dev/null +++ b/identity-gateway/src/lib/env.ts @@ -0,0 +1,29 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +export const env = { + nodeEnv: process.env.NODE_ENV ?? "development", + port: Number(process.env.PORT ?? 4000), + logLevel: process.env.LOG_LEVEL ?? "info", + databaseUrl: process.env.DATABASE_URL ?? "", + jwtAccessSecret: process.env.JWT_ACCESS_SECRET ?? "", + jwtRefreshSecret: process.env.JWT_REFRESH_SECRET ?? "", + jwtAccessTtl: process.env.JWT_ACCESS_TTL ?? "15m", + jwtRefreshTtl: process.env.JWT_REFRESH_TTL ?? "30d", + cookieDomain: process.env.COOKIE_DOMAIN ?? "", + cookieSecure: process.env.COOKIE_SECURE === "true", +}; + +export const assertEnv = () => { + const required = [ + "DATABASE_URL", + "JWT_ACCESS_SECRET", + "JWT_REFRESH_SECRET", + ]; + + const missing = required.filter((key) => !process.env[key]); + if (missing.length > 0) { + throw new Error(`Missing environment variables: ${missing.join(", ")}`); + } +}; diff --git a/identity-gateway/src/lib/logger.ts b/identity-gateway/src/lib/logger.ts new file mode 100644 index 0000000..9d9fee2 --- /dev/null +++ b/identity-gateway/src/lib/logger.ts @@ -0,0 +1,13 @@ +import pino from "pino"; +import { env } from "./env"; + +export const logger = pino({ + level: env.logLevel, + transport: + env.nodeEnv === "production" + ? undefined + : { + target: "pino-pretty", + options: { colorize: true }, + }, +}); diff --git a/identity-gateway/src/main.ts b/identity-gateway/src/main.ts new file mode 100644 index 0000000..5d669da --- /dev/null +++ b/identity-gateway/src/main.ts @@ -0,0 +1,43 @@ +import Fastify from "fastify"; +import cookie from "fastify-cookie"; +import { assertEnv, env } from "./lib/env"; +import { logger } from "./lib/logger"; +import { TokenService } from "./core/token.service"; +import { UserService } from "./modules/users/user.service"; +import { SessionService } from "./modules/sessions/session.service"; +import { AuthService } from "./modules/auth/auth.service"; +import { LocalProvider } from "./modules/providers/local.provider"; +import { AuthProvider } from "./modules/providers/provider.interface"; +import { registerAuthRoutes } from "./modules/auth/auth.controller"; +import { registerUserRoutes } from "./modules/users/user.controller"; + +const bootstrap = async () => { + assertEnv(); + + const app = Fastify({ logger }); + app.register(cookie); + + const tokenService = new TokenService(); + const userService = new UserService(); + const sessionService = new SessionService(); + + const providers = new Map(); + providers.set("local", new LocalProvider(userService)); + + const authService = new AuthService( + tokenService, + userService, + sessionService, + providers + ); + + registerAuthRoutes(app, authService); + registerUserRoutes(app, userService, tokenService); + + await app.listen({ port: env.port, host: "0.0.0.0" }); +}; + +bootstrap().catch((error) => { + logger.error(error, "Failed to start identity-gateway"); + process.exit(1); +}); diff --git a/identity-gateway/src/modules/auth/auth.controller.ts b/identity-gateway/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..4ff8e05 --- /dev/null +++ b/identity-gateway/src/modules/auth/auth.controller.ts @@ -0,0 +1,62 @@ +import { FastifyInstance } from "fastify"; +import { AuthService } from "./auth.service"; +import { env } from "../../lib/env"; + +export const registerAuthRoutes = (app: FastifyInstance, authService: AuthService) => { + app.post("/auth/login", async (request, reply) => { + const body = request.body as { + provider?: string; + identifier: string; + secret: string; + tenantId: string; + }; + + const provider = body.provider ?? "local"; + const { accessToken, refreshToken } = await authService.authenticate( + provider, + body.identifier, + body.secret, + body.tenantId + ); + + if (env.cookieDomain) { + reply.setCookie("refresh_token", refreshToken, { + httpOnly: true, + secure: env.cookieSecure, + sameSite: "strict", + domain: env.cookieDomain, + path: "/", + }); + } + + return { accessToken, refreshToken }; + }); + + app.post("/auth/refresh", async (request, reply) => { + const body = request.body as { refreshToken?: string }; + const refreshToken = body.refreshToken ?? request.cookies.refresh_token; + if (!refreshToken) { + reply.code(400).send({ message: "Missing refresh token" }); + return; + } + + const { accessToken } = await authService.refresh(refreshToken); + return { accessToken }; + }); + + app.post("/auth/logout", async (request, reply) => { + const body = request.body as { refreshToken?: string }; + const refreshToken = body.refreshToken ?? request.cookies.refresh_token; + if (!refreshToken) { + reply.code(400).send({ message: "Missing refresh token" }); + return; + } + + await authService.revoke(refreshToken); + reply.clearCookie("refresh_token", { + domain: env.cookieDomain || undefined, + path: "/", + }); + return { success: true }; + }); +}; diff --git a/identity-gateway/src/modules/auth/auth.service.ts b/identity-gateway/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..165a515 --- /dev/null +++ b/identity-gateway/src/modules/auth/auth.service.ts @@ -0,0 +1,78 @@ +import { TokenService } from "../../core/token.service"; +import { AuthProvider } from "../providers/provider.interface"; +import { SessionService } from "../sessions/session.service"; +import { UserService } from "../users/user.service"; + +export class AuthService { + constructor( + private readonly tokenService: TokenService, + private readonly userService: UserService, + private readonly sessionService: SessionService, + private readonly providers: Map + ) {} + + async authenticate(providerName: string, identifier: string, secret: string, tenantId: string) { + const provider = this.providers.get(providerName); + if (!provider) { + throw new Error("Auth provider not supported"); + } + + const identity = await provider.validate({ identifier, secret }, tenantId); + const roles = await this.userService.listRolesForTenant(identity.userId, tenantId); + const permissions = await this.userService.listPermissionsForTenant(identity.userId, tenantId); + + const context = { + userId: identity.userId, + tenantId, + roles, + permissions, + }; + + const accessToken = this.tokenService.signAccessToken(context); + const refreshToken = this.tokenService.signRefreshToken(context); + + const refreshExpiry = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await this.sessionService.createSession(identity.userId, tenantId, refreshToken, refreshExpiry); + + return { accessToken, refreshToken, context }; + } + + async refresh(refreshToken: string) { + const payload = this.tokenService.verifyRefreshToken(refreshToken); + const session = await this.sessionService.findValidSession( + payload.userId, + payload.tenantId, + refreshToken + ); + + if (!session) { + throw new Error("Invalid refresh token"); + } + + const roles = await this.userService.listRolesForTenant(payload.userId, payload.tenantId); + const permissions = await this.userService.listPermissionsForTenant(payload.userId, payload.tenantId); + + const context = { + userId: payload.userId, + tenantId: payload.tenantId, + roles, + permissions, + }; + + const accessToken = this.tokenService.signAccessToken(context); + return { accessToken, context }; + } + + async revoke(refreshToken: string) { + const payload = this.tokenService.verifyRefreshToken(refreshToken); + const session = await this.sessionService.findValidSession( + payload.userId, + payload.tenantId, + refreshToken + ); + + if (session) { + await this.sessionService.revokeSession(session.id); + } + } +} diff --git a/identity-gateway/src/modules/permissions/permission.entity.ts b/identity-gateway/src/modules/permissions/permission.entity.ts new file mode 100644 index 0000000..2f89056 --- /dev/null +++ b/identity-gateway/src/modules/permissions/permission.entity.ts @@ -0,0 +1,5 @@ +export interface PermissionEntity { + id: string; + name: string; + description?: string; +} diff --git a/identity-gateway/src/modules/permissions/permission.service.ts b/identity-gateway/src/modules/permissions/permission.service.ts new file mode 100644 index 0000000..5ba5ee2 --- /dev/null +++ b/identity-gateway/src/modules/permissions/permission.service.ts @@ -0,0 +1,19 @@ +import { db } from "../../lib/db"; +import { PermissionEntity } from "./permission.entity"; + +export class PermissionService { + async createPermission(name: string, description?: string) { + const result = await db.query( + "INSERT INTO permissions (name, description) VALUES ($1, $2) RETURNING id, name, description", + [name, description ?? null] + ); + return result.rows[0]; + } + + async listPermissions() { + const result = await db.query( + "SELECT id, name, description FROM permissions ORDER BY name" + ); + return result.rows; + } +} diff --git a/identity-gateway/src/modules/providers/external.provider.ts b/identity-gateway/src/modules/providers/external.provider.ts new file mode 100644 index 0000000..5d97592 --- /dev/null +++ b/identity-gateway/src/modules/providers/external.provider.ts @@ -0,0 +1,12 @@ +import { AuthProvider, ProviderCredentials, ProviderIdentity } from "./provider.interface"; + +export class ExternalProvider implements AuthProvider { + name = "external"; + + async validate( + _credentials: ProviderCredentials, + _tenantId: string + ): Promise { + throw new Error("External provider not configured"); + } +} diff --git a/identity-gateway/src/modules/providers/local.provider.ts b/identity-gateway/src/modules/providers/local.provider.ts new file mode 100644 index 0000000..dcef2bc --- /dev/null +++ b/identity-gateway/src/modules/providers/local.provider.ts @@ -0,0 +1,25 @@ +import { AuthProvider, ProviderCredentials, ProviderIdentity } from "./provider.interface"; +import { UserService } from "../users/user.service"; + +export class LocalProvider implements AuthProvider { + name = "local"; + + constructor(private readonly userService: UserService) {} + + async validate( + credentials: ProviderCredentials, + tenantId: string + ): Promise { + const user = await this.userService.verifyCredentials( + credentials.identifier, + credentials.secret + ); + + const isMember = await this.userService.isMemberOfTenant(user.id, tenantId); + if (!isMember) { + throw new Error("User is not assigned to tenant"); + } + + return { userId: user.id, tenantId }; + } +} diff --git a/identity-gateway/src/modules/providers/provider.interface.ts b/identity-gateway/src/modules/providers/provider.interface.ts new file mode 100644 index 0000000..285192f --- /dev/null +++ b/identity-gateway/src/modules/providers/provider.interface.ts @@ -0,0 +1,14 @@ +export interface ProviderIdentity { + userId: string; + tenantId: string; +} + +export interface ProviderCredentials { + identifier: string; + secret: string; +} + +export interface AuthProvider { + name: string; + validate(credentials: ProviderCredentials, tenantId: string): Promise; +} diff --git a/identity-gateway/src/modules/roles/role.entity.ts b/identity-gateway/src/modules/roles/role.entity.ts new file mode 100644 index 0000000..dba0364 --- /dev/null +++ b/identity-gateway/src/modules/roles/role.entity.ts @@ -0,0 +1,5 @@ +export interface RoleEntity { + id: string; + name: string; + description?: string; +} diff --git a/identity-gateway/src/modules/roles/role.service.ts b/identity-gateway/src/modules/roles/role.service.ts new file mode 100644 index 0000000..ac22f35 --- /dev/null +++ b/identity-gateway/src/modules/roles/role.service.ts @@ -0,0 +1,19 @@ +import { db } from "../../lib/db"; +import { RoleEntity } from "./role.entity"; + +export class RoleService { + async createRole(name: string, description?: string) { + const result = await db.query( + "INSERT INTO roles (name, description) VALUES ($1, $2) RETURNING id, name, description", + [name, description ?? null] + ); + return result.rows[0]; + } + + async listRoles() { + const result = await db.query( + "SELECT id, name, description FROM roles ORDER BY name" + ); + return result.rows; + } +} diff --git a/identity-gateway/src/modules/sessions/session.entity.ts b/identity-gateway/src/modules/sessions/session.entity.ts new file mode 100644 index 0000000..290a1be --- /dev/null +++ b/identity-gateway/src/modules/sessions/session.entity.ts @@ -0,0 +1,9 @@ +export interface SessionEntity { + id: string; + userId: string; + tenantId: string; + refreshTokenHash: string; + expiresAt: string; + createdAt: string; + revokedAt?: string | null; +} diff --git a/identity-gateway/src/modules/sessions/session.service.ts b/identity-gateway/src/modules/sessions/session.service.ts new file mode 100644 index 0000000..4488841 --- /dev/null +++ b/identity-gateway/src/modules/sessions/session.service.ts @@ -0,0 +1,40 @@ +import { db } from "../../lib/db"; +import { hashSecret, verifySecret } from "../../lib/crypto"; +import { SessionEntity } from "./session.entity"; + +export class SessionService { + async createSession(userId: string, tenantId: string, refreshToken: string, expiresAt: Date) { + const refreshTokenHash = await hashSecret(refreshToken); + const result = await db.query( + `INSERT INTO sessions (user_id, tenant_id, refresh_token_hash, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id as "userId", tenant_id as "tenantId", refresh_token_hash as "refreshTokenHash", expires_at as "expiresAt", created_at as "createdAt", revoked_at as "revokedAt"`, + [userId, tenantId, refreshTokenHash, expiresAt] + ); + return result.rows[0]; + } + + async findValidSession(userId: string, tenantId: string, refreshToken: string) { + const result = await db.query( + `SELECT id, user_id as "userId", tenant_id as "tenantId", refresh_token_hash as "refreshTokenHash", expires_at as "expiresAt", created_at as "createdAt", revoked_at as "revokedAt" + FROM sessions + WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 5`, + [userId, tenantId] + ); + + for (const session of result.rows) { + const valid = await verifySecret(refreshToken, session.refreshTokenHash); + if (valid) { + return session; + } + } + + return null; + } + + async revokeSession(sessionId: string) { + await db.query("UPDATE sessions SET revoked_at = NOW() WHERE id = $1", [sessionId]); + } +} diff --git a/identity-gateway/src/modules/users/user.controller.ts b/identity-gateway/src/modules/users/user.controller.ts new file mode 100644 index 0000000..71710af --- /dev/null +++ b/identity-gateway/src/modules/users/user.controller.ts @@ -0,0 +1,25 @@ +import { FastifyInstance } from "fastify"; +import { UserService } from "./user.service"; +import { authGuard } from "../../core/auth.guard"; +import { TokenService } from "../../core/token.service"; +import { getTenantContext } from "../../core/tenant.context"; + +export const registerUserRoutes = ( + app: FastifyInstance, + userService: UserService, + tokenService: TokenService +) => { + app.get( + "/users/me", + { preHandler: authGuard(tokenService) }, + async (request) => { + const context = getTenantContext(request); + const user = await userService.findById(context.userId); + return { + id: user.id, + identifier: user.identifier, + status: user.status, + }; + } + ); +}; diff --git a/identity-gateway/src/modules/users/user.entity.ts b/identity-gateway/src/modules/users/user.entity.ts new file mode 100644 index 0000000..dd1a7a0 --- /dev/null +++ b/identity-gateway/src/modules/users/user.entity.ts @@ -0,0 +1,7 @@ +export interface UserEntity { + id: string; + identifier: string; + passwordHash: string; + status: "active" | "disabled"; + createdAt: string; +} diff --git a/identity-gateway/src/modules/users/user.service.ts b/identity-gateway/src/modules/users/user.service.ts new file mode 100644 index 0000000..4cdef17 --- /dev/null +++ b/identity-gateway/src/modules/users/user.service.ts @@ -0,0 +1,79 @@ +import { db } from "../../lib/db"; +import { hashSecret, verifySecret } from "../../lib/crypto"; +import { UserEntity } from "./user.entity"; + +export class UserService { + async createUser(identifier: string, password: string) { + const passwordHash = await hashSecret(password); + const result = await db.query( + "INSERT INTO users (identifier, password_hash) VALUES ($1, $2) RETURNING id, identifier, password_hash as \"passwordHash\", status, created_at as \"createdAt\"", + [identifier, passwordHash] + ); + return result.rows[0]; + } + + async findByIdentifier(identifier: string) { + const result = await db.query( + "SELECT id, identifier, password_hash as \"passwordHash\", status, created_at as \"createdAt\" FROM users WHERE identifier = $1", + [identifier] + ); + if (result.rowCount === 0) { + throw new Error("User not found"); + } + return result.rows[0]; + } + + async findById(userId: string) { + const result = await db.query( + "SELECT id, identifier, password_hash as \"passwordHash\", status, created_at as \"createdAt\" FROM users WHERE id = $1", + [userId] + ); + if (result.rowCount === 0) { + throw new Error("User not found"); + } + return result.rows[0]; + } + + async verifyCredentials(identifier: string, password: string) { + const user = await this.findByIdentifier(identifier); + if (user.status !== "active") { + throw new Error("User disabled"); + } + const valid = await verifySecret(password, user.passwordHash); + if (!valid) { + throw new Error("Invalid credentials"); + } + return user; + } + + async isMemberOfTenant(userId: string, tenantId: string) { + const result = await db.query<{ exists: boolean }>( + "SELECT EXISTS (SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2) as \"exists\"", + [userId, tenantId] + ); + return result.rows[0]?.exists ?? false; + } + + async listRolesForTenant(userId: string, tenantId: string) { + const result = await db.query<{ name: string }>( + `SELECT roles.name + FROM user_roles + JOIN roles ON roles.id = user_roles.role_id + WHERE user_roles.user_id = $1 AND user_roles.tenant_id = $2`, + [userId, tenantId] + ); + return result.rows.map((row) => row.name); + } + + async listPermissionsForTenant(userId: string, tenantId: string) { + const result = await db.query<{ name: string }>( + `SELECT permissions.name + FROM user_roles + JOIN role_permissions ON role_permissions.role_id = user_roles.role_id + JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE user_roles.user_id = $1 AND user_roles.tenant_id = $2`, + [userId, tenantId] + ); + return result.rows.map((row) => row.name); + } +} diff --git a/identity-gateway/tsconfig.json b/identity-gateway/tsconfig.json new file mode 100644 index 0000000..500c7de --- /dev/null +++ b/identity-gateway/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}