first commit
This commit is contained in:
11
libs/api/identity/application/src/index.ts
Normal file
11
libs/api/identity/application/src/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
60
libs/api/identity/application/src/lib/identity.service.ts
Normal file
60
libs/api/identity/application/src/lib/identity.service.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user