feat: implement mailer support

feat: basic structure
This commit is contained in:
Kakious 2024-07-15 14:17:40 -04:00
parent 60532e83cf
commit ca8ee728fb
26 changed files with 2410 additions and 285 deletions

View file

@ -1,73 +1,19 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
## Waterwolf Identity Solution
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
This is a re-imagination on how BOOP works with an optimized OAUTH 2.0 flow. This will hook into the existing user database to read usernames and passwords.
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ pnpm install
```
## Running the app
Use pnmp to install the required packages.
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
pnpm install
```
## Test
## Usage
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
pnpm start
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View file

@ -17,37 +17,52 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"prepare": "ts-patch install && typia patch"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
"@atech/postal": "^1.0.0",
"@nestjs/common": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.10",
"@nestjs/platform-express": "^10.3.10",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"mysql2": "^3.10.2",
"nanoid": "^5.0.7",
"nestjs-postal-client": "^0.0.5",
"oidc-provider": "^8.5.1",
"pug": "^3.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sequelize": "^6.37.3",
"sequelize-typescript": "^2.1.6",
"typia": "^6.5.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"@nestjs/cli": "^10.4.2",
"@nestjs/schematics": "^10.1.2",
"@nestjs/testing": "^10.3.10",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.10",
"@types/sequelize": "^4.28.20",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"prettier": "^3.3.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"ts-jest": "^29.2.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"ts-patch": "^3.2.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
"typescript": "^5.5.3"
},
"jest": {
"moduleFileExtensions": [

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,30 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { PostalClientService } from 'nestjs-postal-client';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
constructor(
private readonly appService: AppService,
private readonly postal: PostalClientService,
) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('email_test')
async getEmailTest(): Promise<any> {
await this.postal.sendMessage({
to: ['kakious@kakio.us'],
subject: 'Test email',
from: 'kakious@kakio.us',
plain_body: 'This is a test email',
});
return {
message: 'Email sent',
};
}
}

View file

@ -1,9 +1,19 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import config from './config/config';
import { MailModule } from './mail/mail.module';
@Module({
imports: [],
imports: [
ConfigModule.forRoot({
cache: true,
isGlobal: true,
load: [config],
}),
MailModule,
],
controllers: [AppController],
providers: [AppService],
})

10
src/auth/auth.module.ts Normal file
View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [],
controllers: [],
providers: [ConfigService],
exports: [],
})
export class AuthModule {}

View file

View file

View file

0
src/auth/oidc/adapter.ts Normal file
View file

View file

@ -0,0 +1,26 @@
let nanoidImport: typeof import('nanoid') | null = null;
const getNanoId = async () => {
if (!nanoidImport) {
// This is an async import as it's only esm and we need to use it in a commonjs environment because
// nestjs doesn't work very well with esm yet.
nanoidImport = await (eval(
"import('nanoid')",
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
) as Promise<typeof import('nanoid')>);
}
return nanoidImport;
};
const generateId = async (length = 21, charset?: string): Promise<string> => {
const nanoid = await getNanoId();
if (charset) {
return nanoid.customAlphabet(charset, length)();
}
return nanoid.nanoid(length);
};
export default generateId;

View file

@ -0,0 +1,13 @@
export interface AccessToken {
iat: number;
exp: number;
accountId?: string;
expiresWithSession: boolean;
grantId: string;
gty: string;
sessionUid: string;
clientId: string;
scope: string;
kind: string;
jti: string;
}

View file

@ -0,0 +1,15 @@
export enum adapterTypes {
Session = 'Session',
AccessToken = 'AccessToken',
AuthorizationCode = 'AuthorizationCode',
RefreshToken = 'RefreshToken',
DeviceCode = 'DeviceCode',
ClientCredentials = 'ClientCredentials',
Client = 'Client',
InitalAccessToken = 'InitalAccessToken',
RegistrationAccessToken = 'RegistrationAccessToken',
Interaction = 'Interaction',
ReplayDetection = 'ReplayDetection',
PushedAuthorizationRequest = 'PushedAuthorizationRequest',
Grant = 'Grant',
}

View file

@ -0,0 +1,77 @@
export interface Interaction {
readonly kind: string;
iat: number;
exp: number;
session?: InteractionSession | undefined;
params: InteractionParams;
prompt: PromptDetail;
result?: InteractionResults | undefined;
returnTo: string;
deviceCode?: string | undefined;
trusted?: string[] | undefined;
uid: string;
lastSubmission?: InteractionResults | undefined;
grantId?: string | undefined;
save(ttl: number): Promise<string>;
persist(): Promise<string>;
}
export interface InteractionSession {
accountId: string;
uid: string;
cookie: string;
acr?: string | undefined;
amr?: string[] | undefined;
}
export type UnknownObject = Record<string, unknown>;
export interface PromptDetail {
name: Prompts | string;
reasons: string[];
details: InteractionDetails;
}
export enum Prompts {
login = 'login',
consent = 'consent',
select_account = 'select_account',
}
export interface InteractionResults {
login?:
| {
remember?: boolean | undefined;
accountId: string;
ts?: number | undefined;
amr?: string[] | undefined;
acr?: string | undefined;
[key: string]: unknown;
}
| undefined;
consent?:
| {
grantId?: string | undefined;
[key: string]: unknown;
}
| undefined;
[key: string]: unknown;
}
export interface InteractionParams extends UnknownObject {
scope?: string | undefined;
client_id?: string | undefined;
}
export interface InteractionDetails extends UnknownObject {
missingOIDCScope?: string[] | undefined;
missingOIDCClaims?: string[] | undefined;
missingResourceScopes?: UnknownObject;
}

View file

@ -0,0 +1,59 @@
export interface VerifiedSessionFromRequest {
session: Session;
//USER: User;
}
export interface Session {
iat: number;
exp: number;
uid: string;
jti: string;
accountId?: string | undefined;
acr?: string | undefined;
amr?: string[] | undefined;
loginTs?: number | undefined;
transient?: boolean | undefined;
state?: UnknownObject | undefined;
authorizations?: Record<string, ClientAuthorizationState> | undefined;
clientId?: string | undefined;
authTime(): string | void;
past(age: number): boolean;
ensureClientContainer(clientId: string): void;
loginAccount(details: {
accountId: string;
acr?: string | undefined;
amr?: string[] | undefined;
loginTs?: number | undefined;
transient?: boolean | undefined;
}): void;
authorizationFor(clientId: string): ClientAuthorizationState | void;
sidFor(clientId: string): string;
sidFor(clientId: string, value: string): void;
grantIdFor(clientId: string): string;
grantIdFor(clientId: string, value: string): void;
save(ttl: number): Promise<string>;
persist(): Promise<string>;
destroy(): Promise<void>;
resetIdentifier(): void;
}
export type UnknownObject = Record<string, unknown>;
export interface ClientAuthorizationState {
persistsLogout?: boolean | undefined;
sid?: string | undefined;
grantId?: string | undefined;
}
/**
* interface BaseModel {
jti: string;
kind: string;
iat?: number | undefined;
exp?: number | undefined;
}
*/

View file

View file

View file

View file

6
src/config/config.ts Normal file
View file

@ -0,0 +1,6 @@
export default async () => ({
mail: {
baseUrl: process.env.MAIL_BASE_URL,
apiKey: process.env.MAIL_API_KEY,
},
});

19
src/mail/mail.module.ts Normal file
View file

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PostalClientModule } from 'nestjs-postal-client';
import postalConfig from './postal.config';
import { PostalConfigService } from './postal_config.service';
@Module({
imports: [
PostalClientModule.registerAsync({
imports: [ConfigModule.forFeature(postalConfig)],
useClass: PostalConfigService,
inject: [ConfigService],
}),
],
controllers: [],
providers: [ConfigService],
exports: [PostalClientModule],
})
export class MailModule {}

23
src/mail/postal.config.ts Normal file
View file

@ -0,0 +1,23 @@
import { registerAs } from '@nestjs/config';
import type { tags } from 'typia';
import typia from 'typia';
import { validateOrThrow } from '../util/validation.util';
export const POSTAL_CONFIG_KEY = 'postal';
const validateEqualsPostalConfig = typia.createValidateEquals<PostalConfig>();
export interface PostalConfig {
POSTAL_BASE_URL: string & tags.Format<'url'>;
POSTAL_API_KEY: string & tags.MinLength<1>;
}
export default registerAs(
POSTAL_CONFIG_KEY,
(): PostalConfig =>
validateOrThrow(validateEqualsPostalConfig, {
POSTAL_BASE_URL: process.env['POSTAL_BASE_URL'],
POSTAL_API_KEY: process.env['POSTAL_API_KEY'],
}),
);

View file

@ -0,0 +1,32 @@
import type {
PostalModuleOptions,
PostalModuleOptionsFactory,
} from 'nestjs-postal-client';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { PostalConfig } from './postal.config';
import { POSTAL_CONFIG_KEY } from './postal.config';
@Injectable()
export class PostalConfigService implements PostalModuleOptionsFactory {
constructor(private readonly configService: ConfigService) {}
createPostalOptions(): Promise<PostalModuleOptions> | PostalModuleOptions {
const postalConfig =
this.configService.getOrThrow<PostalConfig>(POSTAL_CONFIG_KEY);
return {
http: {
headers: {
'User-Agent': 'OIDC-Client',
},
},
postal: {
baseURL: postalConfig.POSTAL_BASE_URL,
apiKey: postalConfig.POSTAL_API_KEY,
},
};
}
}

View file

@ -0,0 +1,55 @@
import type {
ClassConstructor,
ClassTransformOptions,
} from 'class-transformer';
import { plainToClass } from 'class-transformer';
import type { ValidatorOptions } from 'class-validator';
import { validate } from 'class-validator';
import type { IValidation } from 'typia';
export const VALIDATOR_OPTIONS: ValidatorOptions = {
forbidNonWhitelisted: true,
forbidUnknownValues: true,
whitelist: true,
};
export const TRANSFORMER_OPTIONS: ClassTransformOptions = {
excludeExtraneousValues: true,
exposeDefaultValues: true,
};
export async function transformAndValidate<T extends object>(
cls: ClassConstructor<T>,
data: unknown,
): Promise<T> {
const instance = plainToClass(cls, data, TRANSFORMER_OPTIONS);
const errors = await validate(instance, VALIDATOR_OPTIONS);
if (errors.length > 0) {
throw new Error(errors.toString());
}
return instance;
}
function stringifyIError(error: IValidation.IError) {
return ` - Invalid type on ${error.path}, expect to be ${error.expected}, was ${JSON.stringify(
error.value,
)}`;
}
type PartialUndefined<T> = {
[P in keyof T]?: T[P] | undefined;
};
export function validateOrThrow<T>(
validator: (input: unknown) => IValidation<T>,
input: PartialUndefined<T>,
) {
const result = validator(input);
if (result.success) {
return result.data;
}
const body = result.errors.map(stringifyIError).join('\n');
throw new Error(`Validation errors:\n${body}`);
}

View file

@ -12,10 +12,15 @@
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
"noFallthroughCasesInSwitch": false,
"plugins": [
{
"transform": "typia/lib/transform"
}
]
}
}