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,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],
};
}
}