first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# api-identity-application
Casos de uso de identidad.
- **Import**: `@task-stream/api/identity/application`
- **Capa**: `application` · **Tags**: `scope:api-identity`, `type:application`
## Contenido
- `IdentityService`: punto de entrada para los presenters.
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand`.
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
Ver [Autenticación](../../../../docs/05-autenticacion.md).
## Comandos
```sh
npx nx lint api-identity-application
npx nx test api-identity-application # Jest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: 'api-identity-application',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
passWithNoTests: true,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/api/identity/application',
};

View File

@@ -0,0 +1,12 @@
{
"name": "api-identity-application",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/application/src",
"projectType": "library",
"tags": [
"scope:api-identity",
"type:application"
],
"// targets": "to see all targets run: nx show project api-identity-application --web",
"targets": {}
}

View File

@@ -0,0 +1,11 @@
export * from './lib/ports/identity-provider.port';
export * from './lib/ports/session-cache.port';
export * from './lib/ports/user-registry.port';
export * from './lib/commands/handle-identity-webhook.command';
export * from './lib/queries/resolve-session.query';
export * from './lib/queries/user.queries';
export * from './lib/identity-application.handlers';
export * from './lib/identity.service';
export * from './lib/identity-application.module';
export * from './lib/commands/issue-token.command';
export * from './lib/queries/get-authorization-config.query';

View File

@@ -0,0 +1,57 @@
import { Logger } from '@nestjs/common';
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { SessionCache } from '../ports/session-cache.port';
import { UserRegistry } from '../ports/user-registry.port';
/** Reacts to Pireon webhooks: `user.deactivated` and `token.revoked`. */
export class HandleIdentityWebhookCommand extends Command<void> {
constructor(
public readonly event: string,
public readonly data: Record<string, unknown>,
) {
super();
}
}
@CommandHandler(HandleIdentityWebhookCommand)
export class HandleIdentityWebhookHandler
implements ICommandHandler<HandleIdentityWebhookCommand>
{
private readonly logger = new Logger(HandleIdentityWebhookHandler.name);
constructor(
private readonly cache: SessionCache,
private readonly registry: UserRegistry,
) {}
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
const userId =
data['user_id'] ??
data['userId'] ??
(data['user'] as { id?: unknown } | undefined)?.id;
if (userId === undefined || userId === null) {
this.logger.warn(`Webhook ${event} without user id, ignored`);
return;
}
const id = String(userId);
switch (event) {
case 'user.deactivated':
await Promise.all([this.deactivate(id), this.cache.evictUser(id)]);
break;
case 'token.revoked':
// Only token hashes are cached, so drop every session of that user.
await this.cache.evictUser(id);
break;
default:
this.logger.debug(`Unhandled identity webhook ${event}`);
}
}
private async deactivate(userId: string): Promise<void> {
const user = await this.registry.findById(userId);
if (!user) return;
user.deactivate();
await this.registry.upsert(user);
}
}

View File

@@ -0,0 +1,19 @@
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { TokenGrant, TokenSet } from '@task-stream/api/identity/domain';
import { IdentityProvider } from '../ports/identity-provider.port';
/** Signs a user in (or refreshes the session) against the identity server. */
export class IssueTokenCommand extends Command<TokenSet> {
constructor(public readonly grant: TokenGrant) {
super();
}
}
@CommandHandler(IssueTokenCommand)
export class IssueTokenHandler implements ICommandHandler<IssueTokenCommand> {
constructor(private readonly provider: IdentityProvider) {}
execute({ grant }: IssueTokenCommand): Promise<TokenSet> {
return this.provider.issueToken(grant);
}
}

View File

@@ -0,0 +1,21 @@
import { IssueTokenHandler } from './commands/issue-token.command';
import { GetAuthorizationConfigHandler } from './queries/get-authorization-config.query';
import { HandleIdentityWebhookHandler } from './commands/handle-identity-webhook.command';
import { ResolveSessionHandler } from './queries/resolve-session.query';
import {
FindUserHandler,
FindUsersByIdsHandler,
SearchUsersHandler,
} from './queries/user.queries';
export const IdentityCommandHandlers = [
HandleIdentityWebhookHandler,
IssueTokenHandler,
];
export const IdentityQueryHandlers = [
GetAuthorizationConfigHandler,
ResolveSessionHandler,
FindUserHandler,
FindUsersByIdsHandler,
SearchUsersHandler,
];

View File

@@ -0,0 +1,32 @@
import { DynamicModule, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import {
IdentityCommandHandlers,
IdentityQueryHandlers,
} from './identity-application.handlers';
import { IdentityService } from './identity.service';
/**
* Use cases of the identity context. The infrastructure module that
* implements its ports (IdentityProvider, SessionCache, UserRegistry) is
* plugged in by the composition root.
*/
@Module({
imports: [CqrsModule],
providers: [
IdentityService,
...IdentityCommandHandlers,
...IdentityQueryHandlers,
],
exports: [IdentityService],
})
export class IdentityApplicationModule {
static withInfrastructure(
infrastructureModule: Type | DynamicModule,
): DynamicModule {
return {
module: IdentityApplicationModule,
imports: [infrastructureModule],
};
}
}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
AuthorizationConfig,
TokenGrant,
TokenSet,
User,
} from '@task-stream/api/identity/domain';
import { HandleIdentityWebhookCommand } from './commands/handle-identity-webhook.command';
import { IssueTokenCommand } from './commands/issue-token.command';
import { GetAuthorizationConfigQuery } from './queries/get-authorization-config.query';
import { ResolveSessionQuery } from './queries/resolve-session.query';
import {
FindUserQuery,
FindUsersByIdsQuery,
SearchUsersQuery,
} from './queries/user.queries';
/** Entry point of the identity context for presenters (HTTP, GraphQL, WebSocket). */
@Injectable()
export class IdentityService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
/** Who owns this Pireon access token. Throws InvalidAccessTokenError. */
resolveSession(accessToken: string): Promise<User> {
return this.queryBus.execute(new ResolveSessionQuery(accessToken));
}
findUser(userId: string, accessToken?: string): Promise<User | null> {
return this.queryBus.execute(new FindUserQuery(userId, accessToken));
}
findUsers(userIds: string[]): Promise<User[]> {
return this.queryBus.execute(new FindUsersByIdsQuery(userIds));
}
searchUsers(term: string, accessToken?: string, limit = 20): Promise<User[]> {
return this.queryBus.execute(
new SearchUsersQuery(term, accessToken, Math.min(limit, 100)),
);
}
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
return this.commandBus.execute(
new HandleIdentityWebhookCommand(event, data),
);
}
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
issueToken(grant: TokenGrant): Promise<TokenSet> {
return this.commandBus.execute(new IssueTokenCommand(grant));
}
authorizationConfig(): Promise<AuthorizationConfig | null> {
return this.queryBus.execute(new GetAuthorizationConfigQuery());
}
}

View File

@@ -0,0 +1,31 @@
import {
AuthorizationConfig,
TokenGrant,
TokenSet,
User,
} from '@task-stream/api/identity/domain';
export class InvalidAccessTokenError extends Error {
constructor(message = 'Invalid or expired access token') {
super(message);
}
}
/** Outbound port to the external identity server (Pireon OAuth). */
export abstract class IdentityProvider {
/** Resolves who owns the token. Throws InvalidAccessTokenError when rejected. */
abstract whoAmI(accessToken: string): Promise<User>;
/** Looks a user up using the caller's token. Returns null if missing or not visible. */
abstract findUser(id: string, accessToken: string): Promise<User | null>;
abstract listUsers(accessToken: string): Promise<User[]>;
/** Exchanges a grant for tokens. Throws InvalidCredentialsError when rejected. */
abstract issueToken(grant: TokenGrant): Promise<TokenSet>;
/** Null when no OAuth client is configured for browsers. */
abstract authorizationConfig(): AuthorizationConfig | null;
}
export class InvalidCredentialsError extends Error {
constructor(message = 'Invalid credentials') {
super(message);
}
}

View File

@@ -0,0 +1,9 @@
import { User } from '@task-stream/api/identity/domain';
/** Short-lived cache of resolved tokens, so not every request hits the identity server. */
export abstract class SessionCache {
abstract get(accessToken: string): Promise<User | null>;
abstract set(accessToken: string, user: User): Promise<void>;
/** Drops every cached session of a user (deactivation, revoked tokens...). */
abstract evictUser(userId: string): Promise<void>;
}

View File

@@ -0,0 +1,12 @@
import { User } from '@task-stream/api/identity/domain';
/**
* Local read copy of the users that have interacted with task-stream.
* Used to show names/emails without calling the identity server.
*/
export abstract class UserRegistry {
abstract upsert(user: User): Promise<void>;
abstract findById(id: string): Promise<User | null>;
abstract findByIds(ids: string[]): Promise<User[]>;
abstract search(term: string, limit: number): Promise<User[]>;
}

View File

@@ -0,0 +1,16 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { AuthorizationConfig } from '@task-stream/api/identity/domain';
import { IdentityProvider } from '../ports/identity-provider.port';
export class GetAuthorizationConfigQuery extends Query<AuthorizationConfig | null> {}
@QueryHandler(GetAuthorizationConfigQuery)
export class GetAuthorizationConfigHandler
implements IQueryHandler<GetAuthorizationConfigQuery>
{
constructor(private readonly provider: IdentityProvider) {}
async execute(): Promise<AuthorizationConfig | null> {
return this.provider.authorizationConfig();
}
}

View File

@@ -0,0 +1,64 @@
import { User, UserFactory } from '@task-stream/api/identity/domain';
import {
IdentityProvider,
InvalidAccessTokenError,
} from '../ports/identity-provider.port';
import { SessionCache } from '../ports/session-cache.port';
import { UserRegistry } from '../ports/user-registry.port';
import {
ResolveSessionHandler,
ResolveSessionQuery,
} from './resolve-session.query';
const users = new UserFactory();
const ada = users.create({
id: '1',
name: 'Ada',
email: 'ada@example.com',
isActive: true,
roles: ['user'],
appRoles: ['editor'],
});
function setup(user: User = ada) {
const provider = {
whoAmI: jest.fn().mockResolvedValue(user),
findUser: jest.fn(),
listUsers: jest.fn(),
};
const cached = new Map<string, User>();
const cache = {
get: jest.fn(async (t: string) => cached.get(t) ?? null),
set: jest.fn(async (t: string, u: User) => void cached.set(t, u)),
evictUser: jest.fn(),
};
const registry = { upsert: jest.fn(async (_: User) => undefined) };
const handler = new ResolveSessionHandler(
provider as unknown as IdentityProvider,
cache as unknown as SessionCache,
registry as unknown as UserRegistry,
);
return { handler, provider, registry };
}
describe('ResolveSessionHandler', () => {
it('asks Pireon once, then serves from cache', async () => {
const { handler, provider, registry } = setup();
await handler.execute(new ResolveSessionQuery('token'));
const again = await handler.execute(new ResolveSessionQuery('token'));
expect(again).toEqual(ada);
expect(provider.whoAmI).toHaveBeenCalledTimes(1);
expect(registry.upsert).toHaveBeenCalledWith(ada);
});
it('rejects deactivated users', async () => {
const { handler } = setup(
users.create({ ...ada.toSnapshot(), isActive: false }),
);
await expect(
handler.execute(new ResolveSessionQuery('token')),
).rejects.toThrow(InvalidAccessTokenError);
});
});

View File

@@ -0,0 +1,41 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { User } from '@task-stream/api/identity/domain';
import {
IdentityProvider,
InvalidAccessTokenError,
} from '../ports/identity-provider.port';
import { SessionCache } from '../ports/session-cache.port';
import { UserRegistry } from '../ports/user-registry.port';
/** Resolves who owns an access token (cache → Pireon). */
export class ResolveSessionQuery extends Query<User> {
constructor(public readonly accessToken: string) {
super();
}
}
@QueryHandler(ResolveSessionQuery)
export class ResolveSessionHandler
implements IQueryHandler<ResolveSessionQuery>
{
constructor(
private readonly provider: IdentityProvider,
private readonly cache: SessionCache,
private readonly registry: UserRegistry,
) {}
async execute({ accessToken }: ResolveSessionQuery): Promise<User> {
const cached = await this.cache.get(accessToken);
if (cached) return cached;
const user = await this.provider.whoAmI(accessToken);
if (!user.isActive)
throw new InvalidAccessTokenError('User is deactivated');
await Promise.all([
this.cache.set(accessToken, user),
this.registry.upsert(user),
]);
return user;
}
}

View File

@@ -0,0 +1,85 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { User } from '@task-stream/api/identity/domain';
import { IdentityProvider } from '../ports/identity-provider.port';
import { UserRegistry } from '../ports/user-registry.port';
/**
* Finds a user locally, falling back to the identity server with the
* caller's token (and remembering the result).
*/
export class FindUserQuery extends Query<User | null> {
constructor(
public readonly userId: string,
public readonly accessToken?: string,
) {
super();
}
}
export class FindUsersByIdsQuery extends Query<User[]> {
constructor(public readonly userIds: string[]) {
super();
}
}
/**
* Searches users. With a token, the identity server directory is synced
* first so people who never logged in can be found too.
*/
export class SearchUsersQuery extends Query<User[]> {
constructor(
public readonly term: string,
public readonly accessToken?: string,
public readonly limit = 20,
) {
super();
}
}
@QueryHandler(FindUserQuery)
export class FindUserHandler implements IQueryHandler<FindUserQuery> {
constructor(
private readonly registry: UserRegistry,
private readonly provider: IdentityProvider,
) {}
async execute({ userId, accessToken }: FindUserQuery): Promise<User | null> {
const local = await this.registry.findById(userId);
if (local || !accessToken) return local;
const remote = await this.provider.findUser(userId, accessToken);
if (remote) await this.registry.upsert(remote);
return remote;
}
}
@QueryHandler(FindUsersByIdsQuery)
export class FindUsersByIdsHandler
implements IQueryHandler<FindUsersByIdsQuery>
{
constructor(private readonly registry: UserRegistry) {}
execute({ userIds }: FindUsersByIdsQuery): Promise<User[]> {
return this.registry.findByIds([...new Set(userIds)]);
}
}
@QueryHandler(SearchUsersQuery)
export class SearchUsersHandler implements IQueryHandler<SearchUsersQuery> {
constructor(
private readonly registry: UserRegistry,
private readonly provider: IdentityProvider,
) {}
async execute({
term,
accessToken,
limit,
}: SearchUsersQuery): Promise<User[]> {
if (accessToken) {
const remote = await this.provider.listUsers(accessToken).catch(() => []);
await Promise.all(remote.map((u) => this.registry.upsert(u)));
}
return this.registry.search(term, limit);
}
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"],
"target": "es2021",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "bundler",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}