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,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');
}