first commit
This commit is contained in:
81
libs/api/identity/presenters/src/lib/http/auth.controller.ts
Normal file
81
libs/api/identity/presenters/src/lib/http/auth.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user