Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ INTERNAL_JWT_PUBLIC_KEY=
GATEWAY_PORT=3100
API_BASE_URL=http://api:3200

# ── SSO / OIDC (optional) ───────────────────────────────────────────────────
# Federated login through the gateway acting as an OIDC Relying Party. Leave
# ALL of these unset to keep SSO disabled — the gateway then behaves exactly as
# today (zero regression). Declare a provider by setting <NAME>_ISSUER; <NAME>
# becomes the provider id (lowercased) used in /api/v1/auth/sso/<name>/login.
# Client secrets live ONLY here on the gateway — never in the api or the browser.
# A declared-but-incomplete provider fails the gateway fast at boot.
#
# SSO_OKTA_ISSUER=https://your-org.okta.com
# SSO_OKTA_CLIENT_ID=replace-me
# SSO_OKTA_CLIENT_SECRET=replace-me
# SSO_OKTA_REDIRECT_URI=https://app.example.com/api/v1/auth/sso/okta/callback
# SSO_OKTA_SCOPES=openid profile email # optional (default)
# SSO_OKTA_GROUPS_CLAIM=groups # optional
# SSO_OKTA_PERMISSION_MAP=admins:ADMIN;staff:WRITE_SOME_ENTITY,READ_SOME_ENTITY
# SSO_OKTA_POST_LOGOUT_REDIRECT_URI=https://app.example.com/login # optional
# SSO_OKTA_DISPLAY_NAME=Okta # optional (button)
# SSO_OKTA_ICON_KEY=okta # optional (icon)
#
# Signs the short-lived OIDC transaction & logout-hint cookies. Falls back to
# JWT_REFRESH_SECRET if unset; set a dedicated value in production.
# SSO_STATE_SECRET=
# Dev-only escape hatch to allow http/loopback issuers. NEVER enable in prod.
# SSO_ALLOW_INSECURE_ISSUERS=false

# Auth rate limiting (anti brute-force / credential stuffing). Login is keyed by
# IP+email and only counts failed attempts.
LOGIN_RATE_WINDOW_MS=900000
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.12.0'
node-version: '24.14.1'
cache: 'npm'

- name: 📥 Install dependencies
Expand All @@ -42,7 +42,7 @@ jobs:
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.12.0'
node-version: '24.14.1'
cache: 'npm'

- name: 📥 Install dependencies
Expand Down Expand Up @@ -78,7 +78,7 @@ jobs:
# - name: 📦 Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '22.12.0'
# node-version: '24.14.1'
# cache: 'npm'

# - name: 📥 Install dependencies
Expand Down Expand Up @@ -109,7 +109,7 @@ jobs:
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.12.0'
node-version: '24.14.1'
cache: 'npm'

- name: 📥 Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.12.0'
node-version: '24.14.1'

# --ignore-scripts: never run project/dependency lifecycle scripts on the
# privileged runner before we push.
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,5 +194,11 @@ These are the cross-cutting invariants. Layer-specific rules live in each packag
- **Soft deletes on every entity**: `deleted`, `createdAt`, `updatedAt`, `deletedAt`.
- **Git**: feature branches only (`feat/*`, `fix/*`, `docs/*`, `chore/*`), never
commit to `main` or `master`.
- **Always use the `package.json` scripts**, not ad-hoc external commands. Prefer
`npm run build` / `build:<app>`, `npm test` / `test:<app>`, `npm run lint`,
`npm run dev:*` over invoking `nx`, `vitest`, `tsc`, `prettier`, etc. directly.
The scripts encode the right flags, configs and order; raw tooling drifts from
them. If a needed task has no script, add one to `package.json` rather than
documenting a bare command.
- When you implement or change something significant, update the relevant `AGENTS.md`
in the same change so it stays accurate.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,7 @@ En producción, aplicar el SQL manualmente sobre la DB.

### Roadmap

- [ ] SSO/OIDC en el gateway para clientes enterprise (Okta, Azure AD, Auth0)
- [x] SSO/OIDC en el gateway para clientes enterprise (Okta, Azure AD, Auth0) — ver [docs/SECURITY.md → Federación OIDC](docs/SECURITY.md#federación-oidc-sso-okta--azure-ad--auth0)
- [ ] SAML para tenants legacy
- [ ] SCIM 2.0 para aprovisionamiento masivo
- [ ] Multi-tenancy en CRUDs
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/controllers/federated-identity.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import HttpResponser from '@api/adapters/http/http.responser';
import { federatedIdentityService } from '@api/services';
import type { ResolveFederatedUserRequestDTO } from '@dto';
import type { Request, Response } from 'express';

class FederatedIdentityController {
/**
* Resolves or provisions a local user from a validated federated identity.
* Reachable only through `requireInternalAuth` with the `federated.identity`
* scope, so the gateway is the sole legitimate caller. The request body is
* validated by `resolveFederatedUserSchema` before reaching this handler.
*/
resolve = async (req: Request, res: Response) => {
try {
const input = req.body as ResolveFederatedUserRequestDTO;
const result = await federatedIdentityService.resolveOrProvision(input);
return HttpResponser.successJson(res, result);
} catch {
// Generic message on purpose: never leak which branch failed
// (unverified email vs. collision vs. db error).
return HttpResponser.errorJson(
res,
{ message: 'Federated identity could not be resolved.' },
400,
);
}
};
}

const federatedIdentityController = new FederatedIdentityController();
export default federatedIdentityController;
2 changes: 2 additions & 0 deletions apps/api/src/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import federatedIdentityController from './federated-identity.controller';
import internalAuthController from './internal-auth.controller';
import refreshLifecycleController from './refresh-lifecycle.controller';
import userCrudController from './user-crud.controller';

export {
federatedIdentityController,
internalAuthController,
refreshLifecycleController,
userCrudController,
Expand Down
68 changes: 68 additions & 0 deletions apps/api/src/models/federated-identity.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { db } from '@api/adapters/db/pg.connector';
import { FederatedIdentityDTO } from '@dto';
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
} from 'sequelize';

export interface FederatedIdentityModel
extends
FederatedIdentityDTO,
Model<
InferAttributes<FederatedIdentityModel>,
InferCreationAttributes<FederatedIdentityModel>
> {}

const FederatedIdentity = db.define<FederatedIdentityModel>(
'FederatedIdentity',
{
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
},
userId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'user_id',
},
provider: {
type: DataTypes.STRING(50),
allowNull: false,
},
subject: {
type: DataTypes.STRING(255),
allowNull: false,
},
emailAtLink: {
type: DataTypes.STRING(150),
allowNull: true,
field: 'email_at_link',
},
deleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
field: 'createdat',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'updatedat',
},
deletedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'deletedat',
},
},
{ tableName: 'federated_identity', timestamps: false },
);

export default FederatedIdentity;
12 changes: 11 additions & 1 deletion apps/api/src/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import FederatedIdentity, {
FederatedIdentityModel,
} from './federated-identity.model';
import RefreshTokenFamily, {
RefreshTokenFamilyModel,
} from './refresh-token-family.model';
import User, { UserModel } from './user.model';

export { RefreshTokenFamily, RefreshTokenFamilyModel, User, UserModel };
export {
FederatedIdentity,
FederatedIdentityModel,
RefreshTokenFamily,
RefreshTokenFamilyModel,
User,
UserModel,
};
6 changes: 6 additions & 0 deletions apps/api/src/models/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ const User = db.define<UserModel>(
},
password: {
type: DataTypes.STRING(250),
allowNull: true,
},
authSource: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'local',
field: 'auth_source',
},
deleted: {
type: DataTypes.BOOLEAN,
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/routes/federated-identity.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { federatedIdentityController } from '@api/controllers';
import { validate } from '@api/middleware';
import { InternalScope, requireInternalAuth } from '@internal-auth';
import { resolveFederatedUserSchema } from '@dto';
import { Router } from 'express';

const federatedIdentityRouter = Router();

const requireFederatedAuth = requireInternalAuth({
publicKey: process.env.INTERNAL_JWT_PUBLIC_KEY ?? '',
allowedScopes: [InternalScope.FEDERATED_IDENTITY],
});

federatedIdentityRouter.post(
'/resolve',
requireFederatedAuth,
validate(resolveFederatedUserSchema),
federatedIdentityController.resolve,
);

export default federatedIdentityRouter;
4 changes: 4 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from 'express';
import federatedIdentityRouter from './federated-identity.routes';
import healthRouter from './health.routes';
import internalAuthRouter from './internal-auth.routes';
import refreshLifecycleRouter from './refresh-lifecycle.routes';
Expand All @@ -12,6 +13,9 @@ api.use('/internal/auth', internalAuthRouter);
/** gateway → api refresh-token lifecycle (scope refresh.lifecycle) */
api.use('/internal/refresh', refreshLifecycleRouter);

/** gateway → api federated identity resolve/provision (scope federated.identity) */
api.use('/internal/federated', federatedIdentityRouter);

/** business endpoints proxied from the gateway (scope user.request) */
api.use('/v1/user', userCrudRouter);

Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('AuthService (internal)', () => {
id: 1,
email: 'a@b.com',
password: 'hashed',
authSource: 'local',
permissions: ['ADMIN'],
};
vi.mocked(User.findOne).mockResolvedValue(mockUser as never);
Expand All @@ -51,13 +52,40 @@ describe('AuthService (internal)', () => {
it('throws when password mismatch', async () => {
vi.mocked(User.findOne).mockResolvedValue({
password: 'hashed',
authSource: 'local',
} as never);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);

await expect(
authService.validateCredentials('a@b.com', 'bad'),
).rejects.toThrow('Email or password incorrect.');
});

it('rejects federated-only accounts (NULL password) without hitting bcrypt', async () => {
vi.mocked(User.findOne).mockResolvedValue({
email: 'fed@b.com',
password: null,
authSource: 'federated',
} as never);

await expect(
authService.validateCredentials('fed@b.com', 'anything'),
).rejects.toThrow('Email or password incorrect.');
expect(bcrypt.compare).not.toHaveBeenCalled();
});

it('rejects local login when auth_source is not local even if a password exists', async () => {
vi.mocked(User.findOne).mockResolvedValue({
email: 'fed@b.com',
password: 'leftover-hash',
authSource: 'federated',
} as never);

await expect(
authService.validateCredentials('fed@b.com', 'anything'),
).rejects.toThrow('Email or password incorrect.');
expect(bcrypt.compare).not.toHaveBeenCalled();
});
});

describe('getUser', () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class AuthService {
where: { email, deleted: false },
});
if (!user) throw new Error('Email or password incorrect.');
// Federated-only accounts have no local credential (password = NULL,
// auth_source = 'federated'). Reject local login before bcrypt so a
// NULL/empty stored password can never authenticate (auth-bypass defense).
if (!user.password || user.authSource !== 'local') {
throw new Error('Email or password incorrect.');
}
const validPassword = await this.#comparePassword(password, user.password);
if (!validPassword) throw new Error('Email or password incorrect.');
return user;
Expand Down
Loading
Loading