refactor identity-gateway to fastify service
This commit is contained in:
parent
8c4235d7f9
commit
293ab34cec
36 changed files with 906 additions and 86 deletions
10
identity-gateway/.env.example
Normal file
10
identity-gateway/.env.example
Normal file
|
|
@ -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
|
||||
21
identity-gateway/Dockerfile
Normal file
21
identity-gateway/Dockerfile
Normal file
|
|
@ -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"]
|
||||
60
identity-gateway/README.md
Normal file
60
identity-gateway/README.md
Normal file
|
|
@ -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.
|
||||
25
identity-gateway/docker-compose.yml
Normal file
25
identity-gateway/docker-compose.yml
Normal file
|
|
@ -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:
|
||||
31
identity-gateway/docs/architecture.md
Normal file
31
identity-gateway/docs/architecture.md
Normal file
|
|
@ -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.
|
||||
23
identity-gateway/docs/security.md
Normal file
23
identity-gateway/docs/security.md
Normal file
|
|
@ -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.
|
||||
21
identity-gateway/docs/token-model.md
Normal file
21
identity-gateway/docs/token-model.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
32
identity-gateway/package.json
Normal file
32
identity-gateway/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
20
identity-gateway/src/core/auth.guard.ts
Normal file
20
identity-gateway/src/core/auth.guard.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
15
identity-gateway/src/core/permission.guard.ts
Normal file
15
identity-gateway/src/core/permission.guard.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
13
identity-gateway/src/core/rbac.guard.ts
Normal file
13
identity-gateway/src/core/rbac.guard.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
21
identity-gateway/src/core/tenant.context.ts
Normal file
21
identity-gateway/src/core/tenant.context.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
40
identity-gateway/src/core/token.service.ts
Normal file
40
identity-gateway/src/core/token.service.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
11
identity-gateway/src/lib/crypto.ts
Normal file
11
identity-gateway/src/lib/crypto.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
10
identity-gateway/src/lib/db.ts
Normal file
10
identity-gateway/src/lib/db.ts
Normal file
|
|
@ -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();
|
||||
};
|
||||
29
identity-gateway/src/lib/env.ts
Normal file
29
identity-gateway/src/lib/env.ts
Normal file
|
|
@ -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(", ")}`);
|
||||
}
|
||||
};
|
||||
13
identity-gateway/src/lib/logger.ts
Normal file
13
identity-gateway/src/lib/logger.ts
Normal file
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
43
identity-gateway/src/main.ts
Normal file
43
identity-gateway/src/main.ts
Normal file
|
|
@ -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<string, AuthProvider>();
|
||||
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);
|
||||
});
|
||||
62
identity-gateway/src/modules/auth/auth.controller.ts
Normal file
62
identity-gateway/src/modules/auth/auth.controller.ts
Normal file
|
|
@ -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 };
|
||||
});
|
||||
};
|
||||
78
identity-gateway/src/modules/auth/auth.service.ts
Normal file
78
identity-gateway/src/modules/auth/auth.service.ts
Normal file
|
|
@ -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<string, AuthProvider>
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface PermissionEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
|
@ -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<PermissionEntity>(
|
||||
"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<PermissionEntity>(
|
||||
"SELECT id, name, description FROM permissions ORDER BY name"
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
12
identity-gateway/src/modules/providers/external.provider.ts
Normal file
12
identity-gateway/src/modules/providers/external.provider.ts
Normal file
|
|
@ -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<ProviderIdentity> {
|
||||
throw new Error("External provider not configured");
|
||||
}
|
||||
}
|
||||
25
identity-gateway/src/modules/providers/local.provider.ts
Normal file
25
identity-gateway/src/modules/providers/local.provider.ts
Normal file
|
|
@ -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<ProviderIdentity> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
14
identity-gateway/src/modules/providers/provider.interface.ts
Normal file
14
identity-gateway/src/modules/providers/provider.interface.ts
Normal file
|
|
@ -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<ProviderIdentity>;
|
||||
}
|
||||
5
identity-gateway/src/modules/roles/role.entity.ts
Normal file
5
identity-gateway/src/modules/roles/role.entity.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface RoleEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
19
identity-gateway/src/modules/roles/role.service.ts
Normal file
19
identity-gateway/src/modules/roles/role.service.ts
Normal file
|
|
@ -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<RoleEntity>(
|
||||
"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<RoleEntity>(
|
||||
"SELECT id, name, description FROM roles ORDER BY name"
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
9
identity-gateway/src/modules/sessions/session.entity.ts
Normal file
9
identity-gateway/src/modules/sessions/session.entity.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface SessionEntity {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
refreshTokenHash: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt?: string | null;
|
||||
}
|
||||
40
identity-gateway/src/modules/sessions/session.service.ts
Normal file
40
identity-gateway/src/modules/sessions/session.service.ts
Normal file
|
|
@ -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<SessionEntity>(
|
||||
`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<SessionEntity>(
|
||||
`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]);
|
||||
}
|
||||
}
|
||||
25
identity-gateway/src/modules/users/user.controller.ts
Normal file
25
identity-gateway/src/modules/users/user.controller.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
7
identity-gateway/src/modules/users/user.entity.ts
Normal file
7
identity-gateway/src/modules/users/user.entity.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface UserEntity {
|
||||
id: string;
|
||||
identifier: string;
|
||||
passwordHash: string;
|
||||
status: "active" | "disabled";
|
||||
createdAt: string;
|
||||
}
|
||||
79
identity-gateway/src/modules/users/user.service.ts
Normal file
79
identity-gateway/src/modules/users/user.service.ts
Normal file
|
|
@ -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<UserEntity>(
|
||||
"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<UserEntity>(
|
||||
"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<UserEntity>(
|
||||
"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);
|
||||
}
|
||||
}
|
||||
14
identity-gateway/tsconfig.json
Normal file
14
identity-gateway/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in a new issue