first commit
This commit is contained in:
1
libs/api/identity/infrastructure/src/index.ts
Normal file
1
libs/api/identity/infrastructure/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/identity-infrastructure.module';
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user