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"
]
}

View File

@@ -0,0 +1,21 @@
# api-identity-domain
El usuario, tal como lo conoce TaskStream (sus datos vienen de Pireon).
- **Import**: `@task-stream/api/identity/domain`
- **Capa**: `domain` · **Tags**: `scope:api-identity`, `type:domain`
## Contenido
- `User` (agregado): nombre, email, activo, roles globales y roles en el cliente; `hasAppRole()`, `deactivate()`.
- `UserFactory`: construye usuarios a partir de Pireon o de la caché.
- Tipos del inicio de sesión: `TokenGrant`, `TokenSet`, `AuthorizationConfig`.
## Comandos
```sh
npx nx lint api-identity-domain
npx nx test api-identity-domain # 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-domain',
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/domain',
};

View File

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

View File

@@ -0,0 +1,4 @@
export * from './lib/user';
export * from './lib/events/user-deactivated.event';
export * from './lib/factories/user.factory';
export * from './lib/token';

View File

@@ -0,0 +1,9 @@
import { AutowiredEvent, DomainEvent } from '@task-stream/api/shared/domain';
/** Pireon deactivated the user. */
@AutowiredEvent
export class UserDeactivatedEvent extends DomainEvent {
constructor(readonly userId: string) {
super('identity-server');
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { assertThat } from '@task-stream/api/shared/domain';
import { User, UserSnapshot } from '../user';
type UserProps = Omit<UserSnapshot, 'roles' | 'appRoles'> &
Partial<Pick<UserSnapshot, 'roles' | 'appRoles'>>;
/** Users are never created here (Pireon owns them): they are only (re)built. */
@Injectable()
export class UserFactory {
create(props: UserProps): User {
assertThat(props.id.length > 0, 'User id is required');
const user = new User(props.id);
user.name = props.name;
user.email = props.email;
user.isActive = props.isActive;
user.roles = [...(props.roles ?? [])];
user.appRoles = [...(props.appRoles ?? [])];
return user;
}
restore(snapshot: UserProps): User {
return this.create(snapshot);
}
}

View File

@@ -0,0 +1,27 @@
/** How a client proves who it is to get tokens from the identity server. */
export type TokenGrant =
| {
type: 'authorization_code';
code: string;
codeVerifier: string;
redirectUri: string;
}
| { type: 'password'; username: string; password: string }
| { type: 'refresh_token'; refreshToken: string };
export interface TokenSet {
accessToken: string;
refreshToken: string | null;
/** Seconds until the access token expires. */
expiresIn: number;
tokenType: string;
}
/** What a browser needs to start the Authorization Code + PKCE flow. */
export interface AuthorizationConfig {
authorizeUrl: string;
clientId: string;
scopes: string[];
/** Password grant can be disabled on the identity server. */
passwordGrant: boolean;
}

View File

@@ -0,0 +1,56 @@
import { VersionedAggregateRoot } from '@task-stream/api/shared/domain';
import { UserDeactivatedEvent } from './events/user-deactivated.event';
export interface UserSnapshot {
/** Pireon user id (string to stay provider-agnostic). */
id: string;
name: string;
email: string;
isActive: boolean;
/** Global roles in the identity server. */
roles: string[];
/** Roles granted inside this OAuth client (task-stream). */
appRoles: string[];
}
/**
* A person managed by the Pireon identity server. Not event-sourced here
* (Pireon owns users): UserFactory rebuilds it from Pireon or the local cache.
*/
export class User extends VersionedAggregateRoot implements UserSnapshot {
public name!: string;
public email!: string;
public isActive = true;
public roles: string[] = [];
public appRoles: string[] = [];
constructor(id: string) {
super();
this.id = id;
}
/** Global Pireon admins implicitly hold every app role. */
hasAppRole(role: string): boolean {
return this.appRoles.includes(role) || this.roles.includes('admin');
}
deactivate(): void {
if (!this.isActive) return;
this.apply(new UserDeactivatedEvent(this.id));
}
onUserDeactivatedEvent(): void {
this.isActive = false;
}
toSnapshot(): UserSnapshot {
return {
id: this.id,
name: this.name,
email: this.email,
isActive: this.isActive,
roles: [...this.roles],
appRoles: [...this.appRoles],
};
}
}

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,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"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"
]
}

View File

@@ -0,0 +1,21 @@
# api-identity-infrastructure
Adaptadores de salida de identidad.
- **Import**: `@task-stream/api/identity/infrastructure`
- **Capa**: `infrastructure` · **Tags**: `scope:api-identity`, `type:infrastructure`
## Contenido
- `PireonIdentityProvider`: `GET /api/user`, `/api/users` y `POST /oauth/token` de Pireon.
- `RedisSessionCache`: tokens validados, guardados por hash SHA-256 y con TTL.
- `MongoUserRegistry`: copia local de usuarios (colección `users`).
## Comandos
```sh
npx nx lint api-identity-infrastructure
npx nx test api-identity-infrastructure # 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-infrastructure',
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/infrastructure',
};

View File

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

View File

@@ -0,0 +1 @@
export * from './lib/identity-infrastructure.module';

View File

@@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import {
IdentityProvider,
SessionCache,
UserRegistry,
} from '@task-stream/api/identity/application';
import { UserFactory } from '@task-stream/api/identity/domain';
import { MongoUserRegistry } from './mongo/mongo-user.registry';
import { UserDocument, UserSchema } from './mongo/user.schema';
import { PIREON_CONFIG, PireonConfig } from './pireon/pireon.config';
import { PireonIdentityProvider } from './pireon/pireon-identity.provider';
import { RedisSessionCache } from './redis/redis-session.cache';
/** Driven adapters of the identity context: Pireon (HTTP), Redis and MongoDB. */
@Module({
imports: [
MongooseModule.forFeature([
{ name: UserDocument.name, schema: UserSchema },
]),
],
providers: [
UserFactory,
{
provide: PIREON_CONFIG,
inject: [ConfigService],
useFactory: (config: ConfigService): PireonConfig => ({
baseUrl: config.getOrThrow<string>('PIREON_BASE_URL'),
sessionTtlSeconds: Number(config.get('AUTH_SESSION_TTL_SECONDS', 60)),
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
.split(/\s+/)
.filter(Boolean),
passwordGrant:
String(config.get('PIREON_PASSWORD_GRANT', 'true')) !== 'false',
}),
},
{ provide: IdentityProvider, useClass: PireonIdentityProvider },
{ provide: SessionCache, useClass: RedisSessionCache },
{ provide: UserRegistry, useClass: MongoUserRegistry },
],
exports: [IdentityProvider, SessionCache, UserRegistry],
})
export class IdentityInfrastructureModule {}

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { UserRegistry } from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain';
import { Model } from 'mongoose';
import { UserDocument } from './user.schema';
@Injectable()
export class MongoUserRegistry extends UserRegistry {
constructor(
@InjectModel(UserDocument.name) private readonly users: Model<UserDocument>,
private readonly factory: UserFactory,
) {
super();
}
async upsert(user: User): Promise<void> {
await this.users.updateOne(
{ _id: user.id },
{ $set: { name: user.name, email: user.email, isActive: user.isActive } },
{ upsert: true },
);
}
async findById(id: string): Promise<User | null> {
const doc = await this.users.findById(id).lean();
return doc ? this.toUser(doc) : null;
}
async findByIds(ids: string[]): Promise<User[]> {
if (ids.length === 0) return [];
const docs = await this.users.find({ _id: { $in: ids } }).lean();
return docs.map((d) => this.toUser(d));
}
async search(term: string, limit: number): Promise<User[]> {
const pattern = new RegExp(
term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'i',
);
const docs = await this.users
.find(term ? { $or: [{ name: pattern }, { email: pattern }] } : {})
.sort({ name: 1 })
.limit(limit)
.lean();
return docs.map((d) => this.toUser(d));
}
private toUser(doc: UserDocument): User {
return this.factory.restore({
id: doc._id,
name: doc.name,
email: doc.email,
isActive: doc.isActive,
});
}
}

View File

@@ -0,0 +1,19 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema({ collection: 'users', timestamps: true, versionKey: false })
export class UserDocument {
@Prop({ type: String, required: true })
_id!: string;
@Prop({ required: true })
name!: string;
@Prop({ required: true, index: true })
email!: string;
@Prop({ default: true })
isActive!: boolean;
}
export const UserSchema = SchemaFactory.createForClass(UserDocument);
UserSchema.index({ name: 'text', email: 'text' });

View File

@@ -0,0 +1,183 @@
import {
Inject,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import {
IdentityProvider,
InvalidAccessTokenError,
InvalidCredentialsError,
} from '@task-stream/api/identity/application';
import {
AuthorizationConfig,
TokenGrant,
TokenSet,
User,
UserFactory,
} from '@task-stream/api/identity/domain';
import { PIREON_CONFIG, PireonConfig } from './pireon.config';
/** User resource as documented in https://oauth.pireon.dev/docs?api-docs.json */
interface PireonUser {
id: number;
name: string;
email: string;
is_active: boolean;
roles?: string[];
app_roles?: string[];
}
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
@Injectable()
export class PireonIdentityProvider extends IdentityProvider {
private readonly logger = new Logger(PireonIdentityProvider.name);
constructor(
@Inject(PIREON_CONFIG) private readonly config: PireonConfig,
private readonly users: UserFactory,
) {
super();
}
async whoAmI(accessToken: string): Promise<User> {
const response = await this.request('/api/user', accessToken);
if (response.status === 401 || response.status === 403) {
throw new InvalidAccessTokenError();
}
this.assertOk(response, 'GET /api/user');
return this.toUser((await response.json()) as PireonUser);
}
async findUser(id: string, accessToken: string): Promise<User | null> {
if (!/^\d+$/.test(id)) return null;
const response = await this.request(`/api/users/${id}`, accessToken);
if ([401, 403, 404].includes(response.status)) return null;
this.assertOk(response, `GET /api/users/${id}`);
return this.toUser((await response.json()) as PireonUser);
}
async listUsers(accessToken: string): Promise<User[]> {
const response = await this.request('/api/users', accessToken);
if (response.status === 401 || response.status === 403) return [];
this.assertOk(response, 'GET /api/users');
const body = (await response.json()) as
| PireonUser[]
| { data: PireonUser[] };
const users = Array.isArray(body) ? body : body.data;
return users.map((u) => this.toUser(u));
}
async issueToken(grant: TokenGrant): Promise<TokenSet> {
const { clientId, clientSecret, scopes } = this.config;
if (!clientId)
throw new ServiceUnavailableException(
'Sign-in is not configured (PIREON_CLIENT_ID)',
);
const body = new URLSearchParams({ client_id: clientId });
if (clientSecret) body.set('client_secret', clientSecret);
switch (grant.type) {
case 'authorization_code':
body.set('grant_type', 'authorization_code');
body.set('code', grant.code);
body.set('code_verifier', grant.codeVerifier);
body.set('redirect_uri', grant.redirectUri);
break;
case 'password':
body.set('grant_type', 'password');
body.set('username', grant.username);
body.set('password', grant.password);
body.set('scope', scopes.join(' '));
break;
case 'refresh_token':
body.set('grant_type', 'refresh_token');
body.set('refresh_token', grant.refreshToken);
break;
}
let response: Response;
try {
response = await fetch(new URL('/oauth/token', this.config.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body,
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
} catch (error) {
this.logger.error(
`Pireon unreachable (/oauth/token): ${(error as Error).message}`,
);
throw new ServiceUnavailableException('Identity server unavailable');
}
if (response.status === 400 || response.status === 401) {
const error = (await response.json().catch(() => ({}))) as {
error_description?: string;
message?: string;
};
throw new InvalidCredentialsError(
error.error_description ?? error.message ?? 'Invalid credentials',
);
}
this.assertOk(response, 'POST /oauth/token');
const token = (await response.json()) as {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
};
return {
accessToken: token.access_token,
refreshToken: token.refresh_token ?? null,
expiresIn: token.expires_in,
tokenType: token.token_type,
};
}
authorizationConfig(): AuthorizationConfig | null {
if (!this.config.clientId) return null;
return {
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
clientId: this.config.clientId,
scopes: this.config.scopes,
passwordGrant: this.config.passwordGrant,
};
}
private toUser(user: PireonUser): User {
return this.users.create({
id: String(user.id),
name: user.name,
email: user.email,
isActive: user.is_active,
roles: user.roles,
appRoles: user.app_roles,
});
}
private async request(path: string, accessToken: string): Promise<Response> {
try {
return await fetch(new URL(path, this.config.baseUrl), {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
} catch (error) {
this.logger.error(
`Pireon unreachable (${path}): ${(error as Error).message}`,
);
throw new ServiceUnavailableException('Identity server unavailable');
}
}
private assertOk(response: Response, operation: string): void {
if (response.ok) return;
this.logger.error(`${operation} answered ${response.status}`);
throw new ServiceUnavailableException('Identity server error');
}
}

View File

@@ -0,0 +1,15 @@
export interface PireonConfig {
/** e.g. https://oauth.pireon.dev */
baseUrl: string;
/** Seconds a resolved token is trusted before asking Pireon again. */
sessionTtlSeconds: number;
requestTimeoutMs: number;
/** OAuth client of task-stream in Pireon (Authorization Code + PKCE / password grant). */
clientId?: string;
/** Kept server-side: browsers sign in through /api/auth/token. */
clientSecret?: string;
scopes: string[];
passwordGrant: boolean;
}
export const PIREON_CONFIG = Symbol('PIREON_CONFIG');

View File

@@ -0,0 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import { SessionCache } from '@task-stream/api/identity/application';
import {
User,
UserFactory,
UserSnapshot,
} from '@task-stream/api/identity/domain';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import { createHash } from 'node:crypto';
import Redis from 'ioredis';
import { PIREON_CONFIG, PireonConfig } from '../pireon/pireon.config';
const sessionKey = (hash: string) => `auth:session:${hash}`;
const userSessionsKey = (userId: string) => `auth:user:${userId}:sessions`;
/** Caches resolved tokens by SHA-256 hash (raw tokens are never stored). */
@Injectable()
export class RedisSessionCache extends SessionCache {
constructor(
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(PIREON_CONFIG) private readonly config: PireonConfig,
private readonly users: UserFactory,
) {
super();
}
async get(accessToken: string): Promise<User | null> {
const raw = await this.redis.get(sessionKey(hash(accessToken)));
return raw ? this.users.restore(JSON.parse(raw) as UserSnapshot) : null;
}
async set(accessToken: string, user: User): Promise<void> {
const tokenHash = hash(accessToken);
const ttl = this.config.sessionTtlSeconds;
await this.redis
.multi()
.set(sessionKey(tokenHash), JSON.stringify(user.toSnapshot()), 'EX', ttl)
.sadd(userSessionsKey(user.id), tokenHash)
.expire(userSessionsKey(user.id), ttl)
.exec();
}
async evictUser(userId: string): Promise<void> {
const hashes = await this.redis.smembers(userSessionsKey(userId));
await this.redis.del(userSessionsKey(userId), ...hashes.map(sessionKey));
}
}
function hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

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"
]
}

View File

@@ -0,0 +1,21 @@
# api-identity-presenters
Adaptadores de entrada de identidad.
- **Import**: `@task-stream/api/identity/presenters`
- **Capa**: `presenters` · **Tags**: `scope:api-identity`, `type:presenters`
## Contenido
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions).
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC).
- `graphql/`: queries `me`, `user` y `users`.
## Comandos
```sh
npx nx lint api-identity-presenters
npx nx test api-identity-presenters # 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-presenters',
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/presenters',
};

View File

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

View File

@@ -0,0 +1,5 @@
export * from './lib/identity-presenters.module';
export * from './lib/auth/auth.decorators';
export * from './lib/auth/auth-request';
export * from './lib/auth/ws-authenticator';
export * from './lib/graphql/user.type';

View File

@@ -0,0 +1,25 @@
import { ExecutionContext } from '@nestjs/common';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
/** Request shape shared by HTTP, GraphQL and GraphQL-over-WebSocket. */
export interface AuthRequest {
headers: Record<string, string | string[] | undefined>;
user?: User;
accessToken?: string;
}
export function getAuthRequest(context: ExecutionContext): AuthRequest {
if (context.getType<GqlContextType>() === 'graphql') {
return GqlExecutionContext.create(context).getContext<{
req: AuthRequest;
}>().req;
}
return context.switchToHttp().getRequest<AuthRequest>();
}
export function extractBearerToken(value: unknown): string | null {
if (typeof value !== 'string') return null;
const [scheme, token] = value.trim().split(/\s+/);
return scheme?.toLowerCase() === 'bearer' && token ? token : null;
}

View File

@@ -0,0 +1,27 @@
import {
ExecutionContext,
SetMetadata,
createParamDecorator,
} from '@nestjs/common';
import { getAuthRequest } from './auth-request';
export const IS_PUBLIC = 'identity:isPublic';
export const REQUIRED_APP_ROLES = 'identity:appRoles';
/** Skips authentication (health checks, webhooks...). */
export const Public = () => SetMetadata(IS_PUBLIC, true);
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
export const RequireAppRoles = (...roles: string[]) =>
SetMetadata(REQUIRED_APP_ROLES, roles);
/** The authenticated caller (User). */
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
);
/** The caller's raw access token, to call Pireon on their behalf. */
export const AccessToken = createParamDecorator(
(_: unknown, context: ExecutionContext) =>
getAuthRequest(context).accessToken,
);

View File

@@ -0,0 +1,56 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
IdentityService,
InvalidAccessTokenError,
} from '@task-stream/api/identity/application';
import { extractBearerToken, getAuthRequest } from './auth-request';
import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly identity: IdentityService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, targets))
return true;
const req = getAuthRequest(context);
// WebSocket subscriptions are authenticated once in connection_init.
if (!req.user) {
const token = extractBearerToken(req.headers['authorization']);
if (!token) throw new UnauthorizedException('Missing bearer token');
try {
req.user = await this.identity.resolveSession(token);
req.accessToken = token;
} catch (error) {
if (error instanceof InvalidAccessTokenError)
throw new UnauthorizedException(error.message);
throw error;
}
}
const roles = this.reflector.getAllAndOverride<string[]>(
REQUIRED_APP_ROLES,
targets,
);
const user = req.user;
if (roles?.length && user && !roles.some((role) => user.hasAppRole(role))) {
throw new ForbiddenException(
`Requires one of the app roles: ${roles.join(', ')}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { IdentityService } from '@task-stream/api/identity/application';
import { AuthRequest, extractBearerToken } from './auth-request';
/**
* Authenticates graphql-ws connections from `connection_init` params:
* `{ "authorization": "Bearer <token>" }`.
*/
@Injectable()
export class WsAuthenticator {
constructor(private readonly identity: IdentityService) {}
async authenticate(
connectionParams: Record<string, unknown> | undefined,
): Promise<AuthRequest> {
const header =
connectionParams?.['authorization'] ??
connectionParams?.['Authorization'];
const token = extractBearerToken(header);
if (!token) throw new Error('Missing bearer token in connection params');
const user = await this.identity.resolveSession(token);
return {
headers: { authorization: `Bearer ${token}` },
user,
accessToken: token,
};
}
}

View File

@@ -0,0 +1,34 @@
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
import { IdentityService } from '@task-stream/api/identity/application';
import { User } from '@task-stream/api/identity/domain';
import { AccessToken, CurrentUser } from '../auth/auth.decorators';
import { MeType, UserType } from './user.type';
@Resolver(() => UserType)
export class UserResolver {
constructor(private readonly identity: IdentityService) {}
@Query(() => MeType, { description: 'The authenticated user' })
me(@CurrentUser() user: User): MeType {
return user.toSnapshot();
}
@Query(() => UserType, { nullable: true })
user(
@Args('id', { type: () => String }) id: string,
@AccessToken() token: string,
): Promise<UserType | null> {
return this.identity.findUser(id, token);
}
@Query(() => [UserType], {
description: 'Search users by name or email (to assign / add members)',
})
users(
@Args('search', { defaultValue: '' }) search: string,
@Args('limit', { type: () => Int, defaultValue: 20 }) limit: number,
@AccessToken() token: string,
): Promise<UserType[]> {
return this.identity.searchUsers(search, token, limit);
}
}

View File

@@ -0,0 +1,29 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
@ObjectType('User', {
description: 'User managed by the Pireon identity server',
})
export class UserType {
@Field(() => ID)
id!: string;
@Field()
name!: string;
@Field()
email!: string;
@Field()
isActive!: boolean;
}
@ObjectType('Me')
export class MeType extends UserType {
@Field(() => [String], { description: 'Global roles in Pireon' })
roles!: string[];
@Field(() => [String], {
description: 'Roles inside the task-stream OAuth client',
})
appRoles!: string[];
}

View File

@@ -0,0 +1,81 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
UnauthorizedException,
} from '@nestjs/common';
import {
IdentityService,
InvalidCredentialsError,
} from '@task-stream/api/identity/application';
import {
AuthorizationConfig,
TokenGrant,
TokenSet,
} from '@task-stream/api/identity/domain';
import { Public } from '../auth/auth.decorators';
import { IssueTokenDto } from './dto/issue-token.dto';
/**
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
* secret must stay server-side, so the web app goes through here.
*/
@Controller('auth')
export class AuthController {
constructor(private readonly identity: IdentityService) {}
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
@Public()
@Get('config')
config(): Promise<AuthorizationConfig | null> {
return this.identity.authorizationConfig();
}
@Public()
@Post('token')
@HttpCode(200)
async token(@Body() dto: IssueTokenDto) {
try {
return toResponse(await this.identity.issueToken(toGrant(dto)));
} catch (error) {
if (error instanceof InvalidCredentialsError)
throw new UnauthorizedException(error.message);
throw error;
}
}
}
function toGrant(dto: IssueTokenDto): TokenGrant {
switch (dto.grant_type) {
case 'authorization_code':
return {
type: 'authorization_code',
code: dto.code as string,
codeVerifier: dto.code_verifier as string,
redirectUri: dto.redirect_uri as string,
};
case 'password':
return {
type: 'password',
username: dto.username as string,
password: dto.password as string,
};
case 'refresh_token':
return {
type: 'refresh_token',
refreshToken: dto.refresh_token as string,
};
}
}
/** Standard OAuth2 token response. */
function toResponse(token: TokenSet) {
return {
access_token: token.accessToken,
refresh_token: token.refreshToken,
expires_in: token.expiresIn,
token_type: token.tokenType,
};
}

View File

@@ -0,0 +1,35 @@
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
export class IssueTokenDto {
@IsIn(['authorization_code', 'password', 'refresh_token'])
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
@IsString()
@IsNotEmpty()
code?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
@IsString()
@IsNotEmpty()
code_verifier?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
@IsUrl({ require_tld: false })
redirect_uri?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
@IsString()
@IsNotEmpty()
username?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
@IsString()
@IsNotEmpty()
password?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
@IsString()
@IsNotEmpty()
refresh_token?: string;
}

View File

@@ -0,0 +1,66 @@
import {
BadRequestException,
Controller,
Headers,
HttpCode,
Post,
RawBodyRequest,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IdentityService } from '@task-stream/api/identity/application';
import { Request } from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Public } from '../auth/auth.decorators';
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
@Controller('webhooks/pireon')
export class PireonWebhookController {
constructor(
private readonly identity: IdentityService,
private readonly config: ConfigService,
) {}
@Public()
@Post()
@HttpCode(204)
async receive(
@Req() req: RawBodyRequest<Request>,
@Headers('x-webhook-signature') signature?: string,
): Promise<void> {
this.verifySignature(req.rawBody, signature);
const body = req.body as {
event?: string;
type?: string;
data?: unknown;
payload?: unknown;
};
const event = body.event ?? body.type;
if (!event) throw new BadRequestException('Missing event name');
const data = (body.data ?? body.payload ?? body) as Record<string, unknown>;
await this.identity.handleWebhook(event, data);
}
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */
private verifySignature(
rawBody: Buffer | undefined,
signature: string | undefined,
): void {
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
if (!secret) {
throw new UnauthorizedException(
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
);
}
if (!rawBody || !signature)
throw new UnauthorizedException('Missing signature');
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = signature.replace(/^sha256=/, '');
const valid =
received.length === expected.length &&
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) throw new UnauthorizedException('Invalid signature');
}
}

View File

@@ -0,0 +1,29 @@
import { DynamicModule, Module, Type } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth/auth.guard';
import { WsAuthenticator } from './auth/ws-authenticator';
import { UserResolver } from './graphql/user.resolver';
import { AuthController } from './http/auth.controller';
import { PireonWebhookController } from './http/pireon-webhook.controller';
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
@Module({
controllers: [AuthController, PireonWebhookController],
providers: [
{ provide: APP_GUARD, useClass: AuthGuard },
WsAuthenticator,
UserResolver,
],
exports: [WsAuthenticator],
})
export class IdentityPresentersModule {
static withApplication(
applicationModule: Type | DynamicModule,
): DynamicModule {
return {
module: IdentityPresentersModule,
imports: [applicationModule],
exports: [WsAuthenticator],
};
}
}

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"
]
}