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