From 5f8ea3e20b9deb63ed81a6a3dc25602608a2f889 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Tue, 9 Apr 2024 21:06:07 -0300 Subject: [PATCH] feat(auth): add facebook sign in/up --- .dockerignore | 35 ++- .env.example | 3 + .github/workflows/api-deploy.yml | 6 +- .github/workflows/dbdocs-deploy.yml | 2 + .github/workflows/dbdocs-validate.yml | 2 + .github/workflows/openapi-deploy.yml | 12 +- .github/workflows/openapi-validate.yml | 2 + .github/workflows/prisma-validate.yml | 2 + .github/workflows/tests-validate.yml | 2 + .gitignore | 1 + Dockerfile.dev | 13 +- README.md | 17 +- docker-compose.yml | 4 +- localstack/buckets.sh | 21 +- ....yaml => auth-with-external-provider.yaml} | 0 openapi/openapi.yaml | 2 + openapi/paths/auth/facebook.yaml | 15 + openapi/paths/auth/google.yaml | 2 +- package.json | 17 +- prisma/.env | 1 + .../migration.sql | 7 + prisma/schema.prisma | 5 +- src/adapters/{google.ts => auth-provider.ts} | 10 +- src/adapters/email.ts | 2 +- .../facebook/facebook.module.ts | 20 ++ .../facebook/facebook.service.ts | 157 ++++++++++ .../implementations/google/google.service.ts | 12 +- src/config.ts | 6 + src/delivery/auth.controller.ts | 24 +- src/delivery/dtos/auth.ts | 2 +- src/models/auth.ts | 22 +- .../postgres/auth/auth-repository.service.ts | 19 ++ src/usecases/auth/auth.module.ts | 4 +- src/usecases/auth/auth.service.ts | 273 +++++++++--------- tests/mocks/adapters/facebook.ts | 55 ++++ tests/mocks/adapters/google.ts | 30 +- tests/mocks/repositories/postgres/auth.ts | 41 +++ tests/src/usecases/auth.spec.ts | 223 +++++++++++++- tsconfig.lint.json | 2 +- 39 files changed, 849 insertions(+), 224 deletions(-) rename openapi/components/schemas/{auth-with-3rd-party.yaml => auth-with-external-provider.yaml} (100%) create mode 100644 openapi/paths/auth/facebook.yaml create mode 100644 prisma/.env create mode 100644 prisma/migrations/20240411010435_add_facebook_provider/migration.sql rename src/adapters/{google.ts => auth-provider.ts} (69%) create mode 100644 src/adapters/implementations/facebook/facebook.module.ts create mode 100644 src/adapters/implementations/facebook/facebook.service.ts create mode 100644 tests/mocks/adapters/facebook.ts diff --git a/.dockerignore b/.dockerignore index bbe697a..8fb50e2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,38 @@ +# Git +/.git +/.gitignore + +# GitHub /.github -/.husky -/.vscode + +# Build /dist /dist-lint -/localstack /node_modules +/coverage + +# DevTools +/.husky +/.vscode +/localstack +/.czrc +/.editorconfig +/.eslintrc.js +/.lintstagedrc +/.nvmrc +/jest.config.json +/LICENSE +/redocly.yaml + +# Env +.env.* +!.env.docker +!/prisma/.env + +# Deploy +/appspec.yml + +# Unnecessary /openapi /scripts +/testes diff --git a/.env.example b/.env.example index 3ecc563..7927161 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ AWS_REGION=us-east-1 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= + PASETO_PRIVATE_KEY=2MPoBMMJwdnHwx5qIso9RcxR5o3SycCgBgWFeHCE2Oz6uI3sCLGOQLqwdmRAPFmU28UPMc9FGuncPy3tpKq+bg== DATABASE_URL=postgresql://username:password@postgres:5432/database?schema=public diff --git a/.github/workflows/api-deploy.yml b/.github/workflows/api-deploy.yml index 51a3a1f..33521ad 100644 --- a/.github/workflows/api-deploy.yml +++ b/.github/workflows/api-deploy.yml @@ -20,6 +20,8 @@ jobs: node-version-file: .nvmrc - uses: pnpm/action-setup@v3 + with: + version: 8 - name: Create .env file uses: SpicyPizza/create-envfile@v2.0 @@ -33,7 +35,9 @@ jobs: envkey_AWS_REGION: ${{ secrets.AWS_REGION }} envkey_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} envkey_GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - envkey_JWT_SECRET: ${{ secrets.JWT_SECRET }} + envkey_FACEBOOK_CLIENT_ID: ${{ secrets.FACEBOOK_CLIENT_ID }} + envkey_FACEBOOK_CLIENT_SECRET: ${{ secrets.FACEBOOK_CLIENT_SECRET }} + envkey_PASETO_PRIVATE_KEY: ${{ secrets.PASETO_PRIVATE_KEY }} envkey_DATABASE_URL: ${{ secrets.DATABASE_URL }} - name: Build diff --git a/.github/workflows/dbdocs-deploy.yml b/.github/workflows/dbdocs-deploy.yml index ace8a83..8e53255 100644 --- a/.github/workflows/dbdocs-deploy.yml +++ b/.github/workflows/dbdocs-deploy.yml @@ -19,6 +19,8 @@ jobs: node-version-file: .nvmrc - uses: pnpm/action-setup@v3 + with: + version: 8 - name: Install prisma & dbdocs run: pnpm add -D dbdocs prisma prisma-dbml-generator diff --git a/.github/workflows/dbdocs-validate.yml b/.github/workflows/dbdocs-validate.yml index 5b69e5c..bbacafa 100644 --- a/.github/workflows/dbdocs-validate.yml +++ b/.github/workflows/dbdocs-validate.yml @@ -19,6 +19,8 @@ jobs: node-version-file: .nvmrc - uses: pnpm/action-setup@v3 + with: + version: 8 - name: Install prisma & dbdocs run: pnpm add --ignore-scripts -D dbdocs prisma prisma-dbml-generator diff --git a/.github/workflows/openapi-deploy.yml b/.github/workflows/openapi-deploy.yml index 9b64063..5910878 100644 --- a/.github/workflows/openapi-deploy.yml +++ b/.github/workflows/openapi-deploy.yml @@ -19,11 +19,19 @@ jobs: node-version-file: .nvmrc - uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Install redocly + run: pnpm add --ignore-scripts -D @redocly/cli - name: Validate openapi run: pnpm run lint:openapi - - name: GitHub Action + - name: Build openapi + run: pnpm run openapi:bundle + + - name: Deploy to readme uses: readmeio/rdme@v8 with: - rdme: docs ./openapi/bundle.yaml --key=${{ secrets.README_API_KEY }} --version=1.0 + rdme: openapi ./openapi/bundle.yaml --key=${{ secrets.README_API_KEY }} --version=1.0 diff --git a/.github/workflows/openapi-validate.yml b/.github/workflows/openapi-validate.yml index 7219795..3dff47e 100644 --- a/.github/workflows/openapi-validate.yml +++ b/.github/workflows/openapi-validate.yml @@ -19,6 +19,8 @@ jobs: node-version-file: .nvmrc - uses: pnpm/action-setup@v3 + with: + version: 8 - name: Install redocly run: pnpm add --ignore-scripts -D @redocly/cli diff --git a/.github/workflows/prisma-validate.yml b/.github/workflows/prisma-validate.yml index 0b06429..51d1552 100644 --- a/.github/workflows/prisma-validate.yml +++ b/.github/workflows/prisma-validate.yml @@ -19,6 +19,8 @@ jobs: node-version-file: .nvmrc - uses: pnpm/action-setup@v3 + with: + version: 8 - name: Install prisma run: pnpm add --ignore-scripts -D prisma diff --git a/.github/workflows/tests-validate.yml b/.github/workflows/tests-validate.yml index 0caee30..89a0443 100644 --- a/.github/workflows/tests-validate.yml +++ b/.github/workflows/tests-validate.yml @@ -20,6 +20,8 @@ jobs: node-version-file: .nvmrc - uses: pnpm/action-setup@v3 + with: + version: 8 - name: Install dependencies run: pnpm install --ignore-scripts diff --git a/.gitignore b/.gitignore index c45209b..7bb3b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ lerna-debug.log* .env .env.* !.env.example +!/prisma/.env # Temp /tmp diff --git a/Dockerfile.dev b/Dockerfile.dev index 5595ff1..3d6742e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,16 +1,11 @@ FROM node:20 -WORKDIR /app - COPY ./package.json ./package.json -COPY ./pnpm-lock.yaml ./pnpm-lock.yaml - -RUN pnpm i --ignore-scripts -ADD ./prisma ./prisma - -RUN pnpm run prepare +RUN npm i --ignore-scripts --no-package-lock ADD ./ ./ -CMD pnpm run start:docker +RUN npm run db:prisma + +CMD npm run start:docker diff --git a/README.md b/README.md index 96ae1f3..9dba2a5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,13 @@ This project use lot's of tools to be as efficient as possible, here's the list - [API](https://econominhas.readme.io/reference/) - [Database](https://dbdocs.io/henriqueleite42/Econominhas?view=relationships) +### Running the API for the first time + +1. Copy and paste .env.example and rename the copy to .env.docker +2. Run pnpm run start:db +3. Open another console tab and run `pnpm run db:gen-migration ` +4. The API will be available at http://localhost:3000/v1 + ## Useful commands | Command | Description | @@ -209,14 +216,4 @@ This phase is were we convert the documentation to code and make everything work -### Running the API for the first time - -1. Copy and paste .env.example and rename the copy to .env -2. Run pnpm run start:dev -3. Run econominhas-api migrations - - Step 2.1: List id container Rundocker ps - - Step 2.2: Run docker exec -it \ sh - - Step 2.3: Run pnpm run db:migrate -4. Available at http://localhost:3000/v1 -
diff --git a/docker-compose.yml b/docker-compose.yml index e10aed6..8859641 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,14 +37,14 @@ services: dockerfile: Dockerfile.dev image: econominhas-api container_name: econominhas-api - command: pnpm run start:docker + command: npm run start:docker depends_on: - postgres - localstack ports: - 3000:3000 env_file: - - .env + - .env.docker volumes: - ./:/app/ networks: diff --git a/localstack/buckets.sh b/localstack/buckets.sh index ae541be..0e672a7 100644 --- a/localstack/buckets.sh +++ b/localstack/buckets.sh @@ -1,18 +1,17 @@ #!/usr/bin/env bash -bucket1="las-musas-public" -bucket2="las-musas-private" +bucket1="econominhas-public" +bucket2="econominhas-private" -##buckets verification if awslocal s3 ls "s3://$bucket1" 2>/dev/null; then - echo "[INFO] $bucket1 bucket already exists" -else - awslocal s3 mb s3://$bucket1 - echo "[INFO] $bucket1 created" + echo "[INFO] $bucket1 bucket already exists" +else + awslocal s3 mb s3://$bucket1 + echo "[INFO] $bucket1 created" fi if awslocal s3 ls "s3://$bucket2" 2>/dev/null; then - echo "[INFO] $bucket2 bucket already exists" -else - awslocal s3 mb s3://$bucket2 - echo "[INFO] $bucket2 created" + echo "[INFO] $bucket2 bucket already exists" +else + awslocal s3 mb s3://$bucket2 + echo "[INFO] $bucket2 created" fi diff --git a/openapi/components/schemas/auth-with-3rd-party.yaml b/openapi/components/schemas/auth-with-external-provider.yaml similarity index 100% rename from openapi/components/schemas/auth-with-3rd-party.yaml rename to openapi/components/schemas/auth-with-external-provider.yaml diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index ff411e0..5f00d69 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -65,6 +65,8 @@ servers: paths: /auth/google: $ref: paths/auth/google.yaml + /auth/facebook: + $ref: paths/auth/facebook.yaml /auth/email: $ref: paths/auth/email.yaml /auth/phone: diff --git a/openapi/paths/auth/facebook.yaml b/openapi/paths/auth/facebook.yaml new file mode 100644 index 0000000..77d1383 --- /dev/null +++ b/openapi/paths/auth/facebook.yaml @@ -0,0 +1,15 @@ +post: + tags: + - Auth + summary: Sign In/Up with Facebook + description: | + Sign Ins or Sign Ups a user using a facebook account + operationId: auth-facebook + requestBody: + content: + application/json: + schema: + $ref: ../../components/schemas/auth-with-external-provider.yaml + required: true + responses: + $ref: ../../components/responses/auth.yaml diff --git a/openapi/paths/auth/google.yaml b/openapi/paths/auth/google.yaml index 766ada6..dfd995d 100644 --- a/openapi/paths/auth/google.yaml +++ b/openapi/paths/auth/google.yaml @@ -9,7 +9,7 @@ post: content: application/json: schema: - $ref: ../../components/schemas/auth-with-3rd-party.yaml + $ref: ../../components/schemas/auth-with-external-provider.yaml required: true responses: $ref: ../../components/responses/auth.yaml diff --git a/package.json b/package.json index ef8b46c..00ecb49 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "type": "git", "url": "git+https://github.com/econominhas/backend.git" }, - "packageManager": "^pnpm@8.15.6", "engines": { "node": ">=20" }, @@ -81,24 +80,24 @@ "webpack-cli": "^5.1.4" }, "scripts": { - "prepare": "husky install && pnpm run db:prisma", - "db:prisma": "pnpm run lint:prisma && prisma generate --generator client", - "db:docs": "pnpm run lint:prisma && prisma generate --generator dbml && dbdocs build ./prisma/schema.dbml", - "db:gen-migration": "pnpm run lint:prisma && prisma migrate dev --name", + "prepare": "husky install && npm run db:prisma", + "db:prisma": "npm run lint:prisma && prisma generate --generator client", + "db:docs": "npm run lint:prisma && prisma generate --generator dbml && dbdocs build ./prisma/schema.dbml", + "db:gen-migration": "npm run lint:prisma && prisma migrate dev --name", "db:migrate": "prisma migrate deploy", "openapi:serve": "redocly preview-docs", - "openapi:bundle": "pnpm run lint:openapi && redocly bundle -o openapi/bundle.yaml", - "openapi:postman": "pnpm run openapi:bundle && openapi2postmanv2 -s openapi/bundle.yaml -o openapi/postman.json -O folderStrategy=Tags,requestParametersResolution=Example", + "openapi:bundle": "npm run lint:openapi && redocly bundle -o openapi/bundle.yaml", + "openapi:postman": "npm run openapi:bundle && openapi2postmanv2 -s openapi/bundle.yaml -o openapi/postman.json -O folderStrategy=Tags,requestParametersResolution=Example", "build": "./scripts/ci-cd/build.sh", "release": "standard-version", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:docker": "nest start --watch", - "start:dev": "docker compose up", + "start:dev": "docker compose up --build", "start:db": "docker compose up postgres", "start:prod": "node main", "clean:docker": "docker container rm econominhas-api && docker image rm econominhas-api", - "lint:all": "pnpm run lint:ts && pnpm run lint:code && pnpm run lint:prisma && pnpm run lint:openapi", + "lint:all": "npm run lint:ts && npm run lint:code && npm run lint:prisma && npm run lint:openapi", "lint:ts": "tsc --project tsconfig.lint.json", "lint:code": "eslint \"src/**/*.ts\" --fix --quiet", "lint:prisma": "prisma-case-format --file prisma/schema.prisma --map-table-case snake,plural --map-field-case snake --map-enum-case snake -p", diff --git a/prisma/.env b/prisma/.env new file mode 100644 index 0000000..4b7177f --- /dev/null +++ b/prisma/.env @@ -0,0 +1 @@ +DATABASE_URL=postgresql://username:password@localhost:5432/database?schema=public diff --git a/prisma/migrations/20240411010435_add_facebook_provider/migration.sql b/prisma/migrations/20240411010435_add_facebook_provider/migration.sql new file mode 100644 index 0000000..0860df2 --- /dev/null +++ b/prisma/migrations/20240411010435_add_facebook_provider/migration.sql @@ -0,0 +1,7 @@ +-- AlterEnum +ALTER TYPE "sign_in_provider_enum" ADD VALUE 'FACEBOOK'; + +-- AlterTable +ALTER TABLE "sign_in_providers" ALTER COLUMN "access_token" SET DATA TYPE VARCHAR(300), +ALTER COLUMN "refresh_token" DROP NOT NULL, +ALTER COLUMN "refresh_token" SET DATA TYPE VARCHAR(300); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f48e428..f7833f0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ datasource db { // ********************************** enum SignInProviderEnum { + FACEBOOK GOOGLE @@map("sign_in_provider_enum") @@ -64,8 +65,8 @@ model SignInProvider { accountId String @map("account_id") @db.Char(16) provider SignInProviderEnum providerId String @map("provider_id") @db.VarChar(50) - accessToken String @map("access_token") @db.VarChar(250) - refreshToken String @map("refresh_token") @db.VarChar(250) + accessToken String @map("access_token") @db.VarChar(300) + refreshToken String? @map("refresh_token") @db.VarChar(300) expiresAt DateTime @map("expires_at") account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) diff --git a/src/adapters/google.ts b/src/adapters/auth-provider.ts similarity index 69% rename from src/adapters/google.ts rename to src/adapters/auth-provider.ts index fc91ce9..de4faeb 100644 --- a/src/adapters/google.ts +++ b/src/adapters/auth-provider.ts @@ -6,7 +6,7 @@ export interface ExchangeCodeInput { export interface ExchangeCodeOutput { scopes: Array; accessToken: string; - refreshToken: string; + refreshToken?: string; expiresAt: Date; } @@ -17,12 +17,8 @@ export interface GetAuthenticatedUserDataOutput { isEmailVerified: boolean; } -export abstract class GoogleAdapter { - readonly requiredScopes = [ - "https://www.googleapis.com/auth/userinfo.profile", - "openid", - "https://www.googleapis.com/auth/userinfo.email", - ]; +export abstract class AuthProviderAdapter { + abstract requiredScopes: Array; abstract exchangeCode(i: ExchangeCodeInput): Promise; diff --git a/src/adapters/email.ts b/src/adapters/email.ts index 326a39a..216c103 100644 --- a/src/adapters/email.ts +++ b/src/adapters/email.ts @@ -2,7 +2,7 @@ import { type Account } from "@prisma/client"; export const EMAIL_TEMPLATES = { MAGIC_LINK_LOGIN: { - from: "", + from: "no-reply@econominhas.com.br", title: "", body: "", }, diff --git a/src/adapters/implementations/facebook/facebook.module.ts b/src/adapters/implementations/facebook/facebook.module.ts new file mode 100644 index 0000000..d0b276d --- /dev/null +++ b/src/adapters/implementations/facebook/facebook.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import axios from "axios"; + +import { DayJsAdapterModule } from "../dayjs/dayjs.module"; + +import { FacebookAdapterService } from "./facebook.service"; + +@Module({ + imports: [DayJsAdapterModule, ConfigModule], + providers: [ + { + provide: "axios", + useValue: axios, + }, + FacebookAdapterService, + ], + exports: [FacebookAdapterService], +}) +export class FacebookAdapterModule {} diff --git a/src/adapters/implementations/facebook/facebook.service.ts b/src/adapters/implementations/facebook/facebook.service.ts new file mode 100644 index 0000000..9735eee --- /dev/null +++ b/src/adapters/implementations/facebook/facebook.service.ts @@ -0,0 +1,157 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Axios } from "axios"; + +import { DateAdapter } from "adapters/date"; +import { AppConfig } from "config"; + +import { + AuthProviderAdapter, + type ExchangeCodeInput, + type ExchangeCodeOutput, + type GetAuthenticatedUserDataOutput, +} from "../../auth-provider"; +import { DayjsAdapterService } from "../dayjs/dayjs.service"; + +interface ExchangeCodeAPIOutput { + access_token: string; + token_type: string; + expires_in: number; +} + +interface GetAppTokenAPIOutput { + access_token: string; +} + +interface TokenDebugAPIOutput { + data: { + app_id: number; + type: "USER"; + application: string; + expires_at: number; + is_valid: boolean; + issued_at: number; + metadata: { + sso: string; + }; + scopes: Array<"email" | "publish_actions">; + user_id: string; + }; +} + +interface GetUserDataAPIOutput { + id: string; + name: string; + email?: string; +} + +@Injectable() +export class FacebookAdapterService extends AuthProviderAdapter { + readonly requiredScopes = ["public_profile", "email"]; + + constructor( + @Inject("axios") + protected readonly axios: Axios, + + @Inject(DayjsAdapterService) + protected readonly dateAdapter: DateAdapter, + + @Inject(ConfigService) + protected readonly config: AppConfig, + ) { + super(); + } + + async exchangeCode({ + code, + originUrl, + }: ExchangeCodeInput): Promise { + // ALERT: The order of the properties is important, don't change it! + const body = new URLSearchParams(); + body.append("code", code); + body.append("client_id", this.config.get("FACEBOOK_CLIENT_ID")); + body.append("client_secret", this.config.get("FACEBOOK_CLIENT_SECRET")); + if (originUrl) { + body.append("redirect_uri", originUrl); + } + body.append("grant_type", "authorization_code"); + // ALERT: The order of the properties is important, don't change it! + + const result = await this.axios + .post("https://graph.facebook.com/v19.0/oauth/access_token", body, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + }) + .then(r => r.data as ExchangeCodeAPIOutput) + .catch(err => { + throw new Error(JSON.stringify(err?.response?.data || {})); + }); + + const tokenDebug = await this.getTokenData(result.access_token); + + return { + accessToken: result.access_token, + scopes: tokenDebug.scopes, + expiresAt: this.dateAdapter.nowPlus(result.expires_in - 60, "second"), + }; + } + + async getAuthenticatedUserData( + accessToken: string, + ): Promise { + const result = await this.axios + .get( + `https://graph.facebook.com/v19.0/me/?fields=id,name,email&access_token=${accessToken}`, + ) + .then(r => r.data as GetUserDataAPIOutput) + .catch(err => { + throw new Error(JSON.stringify(err?.response?.data || {})); + }); + + return { + id: result.id, + name: result.name, + email: result.email || "foo@bar.com", + isEmailVerified: Boolean(result.email), + }; + } + + // Private + + private async getTokenData(accessToken: string) { + const appToken = await this.axios + .get("https://graph.facebook.com/oauth/access_token", { + params: { + client_id: this.config.get("FACEBOOK_CLIENT_ID"), + client_secret: this.config.get("FACEBOOK_CLIENT_SECRET"), + grant_type: "client_credentials", + }, + }) + .then(r => r.data as GetAppTokenAPIOutput) + .catch(err => { + throw new Error(JSON.stringify(err?.response?.data || {})); + }); + + const tokenDebug = await this.axios + .get("https://graph.facebook.com/debug_token", { + params: { + input_token: accessToken, + access_token: appToken.access_token, + }, + headers: { + Accept: "application/json", + }, + }) + .then(r => (r.data as TokenDebugAPIOutput).data) + .catch(err => { + throw new Error(JSON.stringify(err?.response?.data || {})); + }); + + return { + ...tokenDebug, + appToken: appToken.access_token, + }; + } +} diff --git a/src/adapters/implementations/google/google.service.ts b/src/adapters/implementations/google/google.service.ts index fda6fa0..555186e 100644 --- a/src/adapters/implementations/google/google.service.ts +++ b/src/adapters/implementations/google/google.service.ts @@ -6,11 +6,11 @@ import { DateAdapter } from "adapters/date"; import { AppConfig } from "config"; import { - GoogleAdapter, + AuthProviderAdapter, type ExchangeCodeInput, type ExchangeCodeOutput, type GetAuthenticatedUserDataOutput, -} from "../../google"; +} from "../../auth-provider"; import { DayjsAdapterService } from "../dayjs/dayjs.service"; interface ExchangeCodeAPIOutput { @@ -29,7 +29,13 @@ interface GetUserDataAPIOutput { } @Injectable() -export class GoogleAdapterService extends GoogleAdapter { +export class GoogleAdapterService extends AuthProviderAdapter { + readonly requiredScopes = [ + "https://www.googleapis.com/auth/userinfo.profile", + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ]; + constructor( @Inject("axios") protected readonly axios: Axios, diff --git a/src/config.ts b/src/config.ts index c966e32..e32768d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,12 @@ class EnvVars { @IsString() GOOGLE_CLIENT_SECRET: string; + @IsString() + FACEBOOK_CLIENT_ID: string; + + @IsString() + FACEBOOK_CLIENT_SECRET: string; + @IsString() PASETO_PRIVATE_KEY: string; diff --git a/src/delivery/auth.controller.ts b/src/delivery/auth.controller.ts index 9ead73c..41b1a76 100644 --- a/src/delivery/auth.controller.ts +++ b/src/delivery/auth.controller.ts @@ -14,7 +14,7 @@ import { AuthUseCase } from "models/auth"; import { CreateFromEmailProviderDto, - CreateFromGoogleProviderDto, + CreateFromExternalProviderDto, CreateFromPhoneProviderDto, ExchangeCodeDto, RefreshTokenDto, @@ -32,7 +32,7 @@ export class AuthController { @Public() async createFromGoogleProvider( @Body() - body: CreateFromGoogleProviderDto, + body: CreateFromExternalProviderDto, @Res({ passthrough: true }) res: Response, ) { @@ -48,6 +48,26 @@ export class AuthController { return data; } + @Post("/facebook") + @Public() + async createFromFacebookProvider( + @Body() + body: CreateFromExternalProviderDto, + @Res({ passthrough: true }) + res: Response, + ) { + const { isFirstAccess, ...data } = + await this.authService.createFromFacebookProvider(body); + + if (isFirstAccess) { + res.status(HttpStatus.CREATED); + } else { + res.status(HttpStatus.OK); + } + + return data; + } + @HttpCode(HttpStatus.NO_CONTENT) @Post("/email") @Public() diff --git a/src/delivery/dtos/auth.ts b/src/delivery/dtos/auth.ts index cb3dbf4..dd37647 100644 --- a/src/delivery/dtos/auth.ts +++ b/src/delivery/dtos/auth.ts @@ -3,7 +3,7 @@ import { IsEmail, IsOptional, IsString } from "class-validator"; import { IsID, IsSecretCode } from "../validators/internal"; import { IsPhone, IsURL } from "../validators/miscellaneous"; -export class CreateFromGoogleProviderDto { +export class CreateFromExternalProviderDto { @IsString() code: string; diff --git a/src/models/auth.ts b/src/models/auth.ts index 0c92653..6bf5eea 100644 --- a/src/models/auth.ts +++ b/src/models/auth.ts @@ -27,8 +27,20 @@ export interface CreateWithGoogle { expiresAt: Date; }; } +export interface CreateWithFacebook { + email: string; + facebook: { + id: string; + accessToken: string; + expiresAt: Date; + }; +} -export type CreateInput = CreateWithEmail | CreateWithGoogle | CreateWithPhone; +export type CreateInput = + | CreateWithEmail + | CreateWithFacebook + | CreateWithGoogle + | CreateWithPhone; export interface GetByEmailInput { email: string; @@ -106,7 +118,7 @@ export interface RefreshOutput { expiresAt: string; } -export interface CreateWith3rdPartyProviderInput { +export interface CreateWithExternalProviderInput { code: string; originUrl?: string; } @@ -130,7 +142,11 @@ export interface RefreshTokenInput { export abstract class AuthUseCase { abstract createFromGoogleProvider( - i: CreateWith3rdPartyProviderInput, + i: CreateWithExternalProviderInput, + ): Promise; + + abstract createFromFacebookProvider( + i: CreateWithExternalProviderInput, ): Promise; abstract createFromEmailProvider( diff --git a/src/repositories/postgres/auth/auth-repository.service.ts b/src/repositories/postgres/auth/auth-repository.service.ts index 15b68d7..762b8cc 100644 --- a/src/repositories/postgres/auth/auth-repository.service.ts +++ b/src/repositories/postgres/auth/auth-repository.service.ts @@ -17,6 +17,7 @@ import { type CreateWithEmail, type UpdateProviderInput, type GetManyByProviderOutput, + CreateWithFacebook, } from "models/auth"; import { IdAdapter } from "adapters/id"; import { UIDAdapterService } from "adapters/implementations/uid/uid.service"; @@ -73,6 +74,24 @@ export class AuthRepositoryService extends AuthRepository { }); } + const iAsFacebook = i as CreateWithFacebook; + if (iAsFacebook.facebook) { + return this.accountRepository.create({ + data: { + ...baseAccount, + email: iAsFacebook.email, + signInProviders: { + create: { + provider: SignInProviderEnum.FACEBOOK, + providerId: iAsFacebook.facebook.id, + accessToken: iAsFacebook.facebook.accessToken, + expiresAt: iAsFacebook.facebook.expiresAt, + }, + }, + }, + }); + } + const iAsPhone = i as CreateWithPhone; if (iAsPhone.phone) { return this.accountRepository.create({ diff --git a/src/usecases/auth/auth.module.ts b/src/usecases/auth/auth.module.ts index d1247e9..068ec54 100644 --- a/src/usecases/auth/auth.module.ts +++ b/src/usecases/auth/auth.module.ts @@ -8,7 +8,7 @@ import { GoogleAdapterModule } from "adapters/implementations/google/google.modu import { PasetoAdapterModule } from "adapters/implementations/paseto/paseto.module"; import { SESAdapterModule } from "adapters/implementations/ses/ses.module"; import { SNSSMSAdapterModule } from "adapters/implementations/sns-sms/sns.module"; -import { SNSAdapterModule } from "adapters/implementations/sns/sns.module"; +import { FacebookAdapterModule } from "adapters/implementations/facebook/facebook.module"; import { TermsAndPoliciesModule } from "../terms-and-policies/terms-and-policies.module"; @@ -22,10 +22,10 @@ import { AuthService } from "./auth.service"; RefreshTokenRepositoryModule, GoogleAdapterModule, + FacebookAdapterModule, PasetoAdapterModule, SESAdapterModule, SNSSMSAdapterModule, - SNSAdapterModule, TermsAndPoliciesModule, ], diff --git a/src/usecases/auth/auth.service.ts b/src/usecases/auth/auth.service.ts index 73673b6..093bcc5 100644 --- a/src/usecases/auth/auth.service.ts +++ b/src/usecases/auth/auth.service.ts @@ -9,13 +9,14 @@ import { } from "@nestjs/common"; import { SignInProviderEnum, type Account } from "@prisma/client"; +import { AuthProviderAdapter } from "adapters/auth-provider"; import { RefreshTokenRepositoryService } from "repositories/postgres/refresh-token/refresh-token-repository.service"; import { MagicLinkCodeRepositoryService } from "repositories/postgres/magic-link-code/magic-link-code-repository.service"; import { AuthRepository, AuthUseCase, type AuthOutput, - type CreateWith3rdPartyProviderInput, + type CreateWithExternalProviderInput, type CreateWithEmailProviderInput, type CreateWithPhoneProviderInput, type ExchangeCodeInput, @@ -26,19 +27,23 @@ import { AuthRepositoryService } from "repositories/postgres/auth/auth-repositor import { TermsAndPoliciesUseCase } from "models/terms-and-policies"; import { MagicLinkCodeRepository } from "models/magic-link-code"; import { RefreshTokenRepository } from "models/refresh-token"; -import { GoogleAdapter } from "adapters/google"; import { TokenAdapter } from "adapters/token"; import { EmailAdapter } from "adapters/email"; import { SmsAdapter } from "adapters/sms"; import { GoogleAdapterService } from "adapters/implementations/google/google.service"; import { SESAdapterService } from "adapters/implementations/ses/ses.service"; import { SNSSMSAdapterService } from "adapters/implementations/sns-sms/sns.service"; -import { TopicAdapter } from "adapters/topic"; -import { SNSAdapterService } from "adapters/implementations/sns/sns.service"; import { PasetoAdapterService } from "adapters/implementations/paseto/paseto.service"; +import { FacebookAdapterService } from "adapters/implementations/facebook/facebook.service"; import { TermsAndPoliciesService } from "../terms-and-policies/terms-and-policies.service"; +interface CreateFromExternalProviderInput + extends CreateWithExternalProviderInput { + provider: AuthProviderAdapter; + providerType: SignInProviderEnum; +} + interface GenTokensInput { accountId: string; isFirstAccess: boolean; @@ -54,132 +59,45 @@ export class AuthService extends AuthUseCase { private readonly magicLinkCodeRepository: MagicLinkCodeRepository, @Inject(RefreshTokenRepositoryService) private readonly refreshTokenRepository: RefreshTokenRepository, + @Inject(TermsAndPoliciesService) private readonly termsAndPoliciesService: TermsAndPoliciesUseCase, + @Inject(GoogleAdapterService) - private readonly googleAdapter: GoogleAdapter, + private readonly googleAdapter: AuthProviderAdapter, + @Inject(FacebookAdapterService) + private readonly facebookAdapter: AuthProviderAdapter, @Inject(PasetoAdapterService) private readonly tokenAdapter: TokenAdapter, @Inject(SESAdapterService) private readonly emailAdapter: EmailAdapter, @Inject(SNSSMSAdapterService) private readonly smsAdapter: SmsAdapter, - @Inject(SNSAdapterService) - private readonly topicAdapter: TopicAdapter, ) { super(); } - async createFromGoogleProvider({ + createFromGoogleProvider({ code, originUrl, - }: CreateWith3rdPartyProviderInput): Promise { - const { scopes, ...providerTokens } = await this.googleAdapter - .exchangeCode({ code, originUrl }) - .catch(err => { - Logger.error(err); - - throw new BadRequestException("Invalid code"); - }); - - const missingScopes = this.googleAdapter.requiredScopes.filter( - s => !scopes.includes(s), - ); - - if (missingScopes.length > 0) { - throw new BadRequestException( - `Missing required scopes: ${missingScopes.join(" ")}`, - ); - } - - const providerData = await this.googleAdapter.getAuthenticatedUserData( - providerTokens.accessToken, - ); - - if (!providerData.isEmailVerified) { - throw new ForbiddenException("Unverified provider email"); - } - - const relatedAccounts = await this.authRepository.getManyByProvider({ - providerId: providerData.id, - email: providerData.email, - provider: SignInProviderEnum.GOOGLE, + }: CreateWithExternalProviderInput): Promise { + return this.createFromExternalProvider({ + provider: this.googleAdapter, + providerType: SignInProviderEnum.GOOGLE, + code, + originUrl, }); + } - let account: Account; - let isFirstAccess = false; - - if (relatedAccounts.length > 0) { - const sameProviderId = relatedAccounts.find(a => - a.signInProviders.find(p => p.providerId === providerData.id), - ); - const sameEmail = relatedAccounts.find( - a => a.email === providerData.email, - ); - - /* - * Has an account with the same email, and it - * isn't linked with another provider account - */ - if ( - sameEmail && - !sameProviderId && - !sameEmail.signInProviders.find( - p => p.provider === SignInProviderEnum.GOOGLE, - ) - ) { - account = sameEmail; - } - - /* - * Account with same provider id (it can have a different email, - * in case that the user updated it in provider or on our platform) - * More descriptive IF: - * if ((sameProviderId && !sameEmail) || (sameProviderId && sameEmail)) { - */ - if (sameProviderId) { - account = sameProviderId; - } - - if (!account) { - throw new ConflictException( - "Error finding account, please contact support", - ); - } - - await this.authRepository.updateProvider({ - accountId: account.id, - provider: SignInProviderEnum.GOOGLE, - providerId: providerData.id, - accessToken: providerTokens.accessToken, - refreshToken: providerTokens.refreshToken, - expiresAt: providerTokens.expiresAt, - }); - } else { - account = await this.authRepository.create({ - email: providerData.email, - google: { - id: providerData.id, - accessToken: providerTokens.accessToken, - refreshToken: providerTokens.refreshToken, - expiresAt: providerTokens.expiresAt, - }, - }); - - await this.topicAdapter.send({ - topicName: "USER_CREATED", - body: { - accountId: account.id, - }, - }); - - isFirstAccess = true; - } - - return this.genAuthOutput({ - accountId: account.id, - isFirstAccess, - refresh: true, + createFromFacebookProvider({ + code, + originUrl, + }: CreateWithExternalProviderInput): Promise { + return this.createFromExternalProvider({ + provider: this.facebookAdapter, + providerType: SignInProviderEnum.FACEBOOK, + code, + originUrl, }); } @@ -196,13 +114,6 @@ export class AuthService extends AuthUseCase { email: i.email, }); - await this.topicAdapter.send({ - topicName: "USER_CREATED", - body: { - accountId: account.id, - }, - }); - isFirstAccess = true; } @@ -234,13 +145,6 @@ export class AuthService extends AuthUseCase { phone: i.phone, }); - await this.topicAdapter.send({ - topicName: "USER_CREATED", - body: { - accountId: account.id, - }, - }); - isFirstAccess = true; } @@ -272,15 +176,6 @@ export class AuthService extends AuthUseCase { throw new NotFoundException("Invalid code"); } - if (magicLinkCode.isFirstAccess) { - await this.topicAdapter.send({ - topicName: "USER_CREATED", - body: { - accountId: magicLinkCode.accountId, - }, - }); - } - return this.genAuthOutput({ accountId: magicLinkCode.accountId, isFirstAccess: magicLinkCode.isFirstAccess, @@ -312,6 +207,112 @@ export class AuthService extends AuthUseCase { * We set their accessibility to public so we can test it */ + public async createFromExternalProvider({ + provider, + providerType, + code, + originUrl, + }: CreateFromExternalProviderInput): Promise { + const { scopes, ...providerTokens } = await provider + .exchangeCode({ code, originUrl }) + .catch(err => { + Logger.error(err); + + throw new BadRequestException("Invalid code"); + }); + + const missingScopes = provider.requiredScopes.filter( + s => !scopes.includes(s), + ); + + if (missingScopes.length > 0) { + throw new BadRequestException( + `Missing required scopes: ${missingScopes.join(" ")}`, + ); + } + + const providerData = await provider.getAuthenticatedUserData( + providerTokens.accessToken, + ); + + if (!providerData.isEmailVerified) { + throw new ForbiddenException("Unverified provider email"); + } + + const relatedAccounts = await this.authRepository.getManyByProvider({ + providerId: providerData.id, + email: providerData.email, + provider: providerType, + }); + + let account: Account; + let isFirstAccess = false; + + if (relatedAccounts.length > 0) { + const sameProviderId = relatedAccounts.find(a => + a.signInProviders.find(p => p.providerId === providerData.id), + ); + const sameEmail = relatedAccounts.find( + a => a.email === providerData.email, + ); + + /* + * Has an account with the same email, and it + * isn't linked with another provider account + */ + if ( + sameEmail && + !sameProviderId && + !sameEmail.signInProviders.find(p => p.provider === providerType) + ) { + account = sameEmail; + } + + /* + * Account with same provider id (it can have a different email, + * in case that the user updated it in provider or on our platform) + * More descriptive IF: + * if ((sameProviderId && !sameEmail) || (sameProviderId && sameEmail)) { + */ + if (sameProviderId) { + account = sameProviderId; + } + + if (!account) { + throw new ConflictException( + "Error finding account, please contact support", + ); + } + + await this.authRepository.updateProvider({ + accountId: account.id, + provider: providerType, + providerId: providerData.id, + accessToken: providerTokens.accessToken, + refreshToken: providerTokens.refreshToken, + expiresAt: providerTokens.expiresAt, + }); + } else { + account = await this.authRepository.create({ + email: providerData.email, + [providerType.toLowerCase()]: { + id: providerData.id, + accessToken: providerTokens.accessToken, + refreshToken: providerTokens.refreshToken, + expiresAt: providerTokens.expiresAt, + }, + }); + + isFirstAccess = true; + } + + return this.genAuthOutput({ + accountId: account.id, + isFirstAccess, + refresh: true, + }); + } + /** * @private */ diff --git a/tests/mocks/adapters/facebook.ts b/tests/mocks/adapters/facebook.ts new file mode 100644 index 0000000..1575b5f --- /dev/null +++ b/tests/mocks/adapters/facebook.ts @@ -0,0 +1,55 @@ +import { FacebookAdapterService } from "../../../src/adapters/implementations/facebook/facebook.service"; +import { type Mock } from "../types"; +import { type AuthProviderAdapter } from "../../../src/adapters/auth-provider"; + +export const makeFacebookAdapterMock = () => { + const mock: Mock> & { + requiredScopes: Array; + } = { + requiredScopes: ["email", "profile"], + exchangeCode: jest.fn(), + getAuthenticatedUserData: jest.fn(), + }; + + const module = { + provide: FacebookAdapterService, + useValue: mock, + }; + + const outputs = { + exchangeCode: { + success: { + scopes: mock.requiredScopes, + accessToken: "accessToken", + refreshToken: "refreshToken", + expiresAt: new Date(), + }, + noScopes: { + scopes: [], + accessToken: "accessToken", + refreshToken: "refreshToken", + expiresAt: new Date(), + }, + }, + getAuthenticatedUserData: { + success: { + id: "providerId", + name: "Foo Bar", + email: "foo@bar.com", + isEmailVerified: true, + }, + unverified: { + id: "providerId", + name: "Foo Bar", + email: "foo@bar.com", + isEmailVerified: false, + }, + }, + }; + + return { + mock, + module, + outputs, + }; +}; diff --git a/tests/mocks/adapters/google.ts b/tests/mocks/adapters/google.ts index 092d11d..ed5362c 100644 --- a/tests/mocks/adapters/google.ts +++ b/tests/mocks/adapters/google.ts @@ -1,12 +1,12 @@ -import type { GoogleAdapter } from '../../../src/adapters/google'; -import { GoogleAdapterService } from '../../../src/adapters/implementations/google/google.service'; -import type { Mock } from '../types'; +import { GoogleAdapterService } from "../../../src/adapters/implementations/google/google.service"; +import { type Mock } from "../types"; +import { type AuthProviderAdapter } from "../../../src/adapters/auth-provider"; export const makeGoogleAdapterMock = () => { - const mock: Mock> & { + const mock: Mock> & { requiredScopes: Array; } = { - requiredScopes: ['email', 'openid', 'email'], + requiredScopes: ["email", "openid", "profile"], exchangeCode: jest.fn(), getAuthenticatedUserData: jest.fn(), }; @@ -20,28 +20,28 @@ export const makeGoogleAdapterMock = () => { exchangeCode: { success: { scopes: mock.requiredScopes, - accessToken: 'accessToken', - refreshToken: 'refreshToken', + accessToken: "accessToken", + refreshToken: "refreshToken", expiresAt: new Date(), }, noScopes: { scopes: [], - accessToken: 'accessToken', - refreshToken: 'refreshToken', + accessToken: "accessToken", + refreshToken: "refreshToken", expiresAt: new Date(), }, }, getAuthenticatedUserData: { success: { - id: 'providerId', - name: 'Foo Bar', - email: 'foo@bar.com', + id: "providerId", + name: "Foo Bar", + email: "foo@bar.com", isEmailVerified: true, }, unverified: { - id: 'providerId', - name: 'Foo Bar', - email: 'foo@bar.com', + id: "providerId", + name: "Foo Bar", + email: "foo@bar.com", isEmailVerified: false, }, }, diff --git a/tests/mocks/repositories/postgres/auth.ts b/tests/mocks/repositories/postgres/auth.ts index fb58047..f60c1a9 100644 --- a/tests/mocks/repositories/postgres/auth.ts +++ b/tests/mocks/repositories/postgres/auth.ts @@ -1,3 +1,5 @@ +/* eslint-disable sonarjs/no-duplicate-string */ + import { SignInProviderEnum } from "@prisma/client"; import { AuthRepositoryService } from "../../../../src/repositories/postgres/auth/auth-repository.service"; @@ -47,6 +49,23 @@ export const makeAuthRepositoryMock = () => { ], }, ], + facebook: [ + { + id: "accountId", + email: "foo@bar.com", + createdAt: new Date(), + signInProviders: [ + { + accountId: "accountId", + provider: SignInProviderEnum.FACEBOOK, + providerId: "providerId", + accessToken: "accessToken", + refreshToken: "refreshToken", + expiresAt: new Date(), + }, + ], + }, + ], sameEmailDifferentGoogle: [ { id: "accountId", @@ -64,6 +83,23 @@ export const makeAuthRepositoryMock = () => { ], }, ], + sameEmailDifferentFacebook: [ + { + id: "accountId", + email: "foo@bar.com", + createdAt: new Date(), + signInProviders: [ + { + accountId: "accountId", + provider: SignInProviderEnum.FACEBOOK, + providerId: "differentProviderId", + accessToken: "accessToken", + refreshToken: "refreshToken", + expiresAt: new Date(), + }, + ], + }, + ], }, create: { successGoogle: { @@ -71,6 +107,11 @@ export const makeAuthRepositoryMock = () => { email: "foo@bar.com", createdAt: new Date(), }, + successFacebook: { + id: "accountId", + email: "foo@bar.com", + createdAt: new Date(), + }, }, }; diff --git a/tests/src/usecases/auth.spec.ts b/tests/src/usecases/auth.spec.ts index f83155c..65ff8a5 100644 --- a/tests/src/usecases/auth.spec.ts +++ b/tests/src/usecases/auth.spec.ts @@ -11,7 +11,7 @@ import { makeTokenAdapterMock } from "../../mocks/adapters/paseto"; import { makeEmailAdapterMock } from "../../mocks/adapters/email"; import { makeSmsAdapterMock } from "../../mocks/adapters/sms"; import { makeAuthRepositoryMock } from "../../mocks/repositories/postgres/auth"; -import { makeTopicAdapterMock } from "../../mocks/adapters/topic"; +import { makeFacebookAdapterMock } from "../../mocks/adapters/facebook"; describe("Usecases > Auth", () => { let service: AuthService; @@ -24,10 +24,10 @@ describe("Usecases > Auth", () => { const termsService = makeTermsServiceMock(); const googleAdapter = makeGoogleAdapterMock(); + const facebookAdapter = makeFacebookAdapterMock(); const tokenAdapter = makeTokenAdapterMock(); const emailAdapter = makeEmailAdapterMock(); const smsAdapter = makeSmsAdapterMock(); - const topicAdapter = makeTopicAdapterMock(); beforeAll(async () => { service = await createTestService(AuthService, { @@ -37,10 +37,10 @@ describe("Usecases > Auth", () => { refreshTokenRepository.module, termsService.module, googleAdapter.module, + facebookAdapter.module, tokenAdapter.module, emailAdapter.module, smsAdapter.module, - topicAdapter.module, ], }); @@ -274,6 +274,223 @@ describe("Usecases > Auth", () => { }); }); + describe("> createFromFacebookProvider", () => { + it("should sign in user", async () => { + facebookAdapter.mock.exchangeCode.mockResolvedValue( + facebookAdapter.outputs.exchangeCode.success, + ); + facebookAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + facebookAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.empty, + ); + authRepository.mock.create.mockResolvedValue( + authRepository.outputs.create.successFacebook, + ); + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + tokenAdapter.mock.genAccess.mockReturnValue( + tokenAdapter.outputs.genAccess.success, + ); + + let result; + try { + result = await service.createFromFacebookProvider({ + code: "code", + }); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + accessToken: tokenAdapter.outputs.genAccess.success.accessToken, + expiresAt: tokenAdapter.outputs.genAccess.success.expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: true, + }); + expect(facebookAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(facebookAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.create).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).toHaveBeenCalled(); + }); + + it("should sign up user (same providerId)", async () => { + facebookAdapter.mock.exchangeCode.mockResolvedValue( + facebookAdapter.outputs.exchangeCode.success, + ); + facebookAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + facebookAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.facebook, + ); + authRepository.mock.updateProvider.mockResolvedValue(undefined); + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + termsService.mock.hasAcceptedLatest.mockResolvedValue(true); + tokenAdapter.mock.genAccess.mockReturnValue( + tokenAdapter.outputs.genAccess.success, + ); + + let result; + try { + result = await service.createFromFacebookProvider({ + code: "code", + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: tokenAdapter.outputs.genAccess.success.accessToken, + expiresAt: tokenAdapter.outputs.genAccess.success.expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: false, + }); + expect(facebookAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(facebookAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.updateProvider).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).toHaveBeenCalled(); + expect(termsService.mock.hasAcceptedLatest).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).toHaveBeenCalled(); + }); + + it("should sign up user (same email)", async () => { + facebookAdapter.mock.exchangeCode.mockResolvedValue( + facebookAdapter.outputs.exchangeCode.success, + ); + facebookAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + facebookAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.email, + ); + authRepository.mock.updateProvider.mockResolvedValue(undefined); + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + termsService.mock.hasAcceptedLatest.mockResolvedValue(true); + tokenAdapter.mock.genAccess.mockReturnValue( + tokenAdapter.outputs.genAccess.success, + ); + + let result; + try { + result = await service.createFromFacebookProvider({ + code: "code", + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: tokenAdapter.outputs.genAccess.success.accessToken, + expiresAt: tokenAdapter.outputs.genAccess.success.expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: false, + }); + expect(facebookAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(facebookAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.updateProvider).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).toHaveBeenCalled(); + expect(termsService.mock.hasAcceptedLatest).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).toHaveBeenCalled(); + }); + + it("should fail if missing scopes", async () => { + facebookAdapter.mock.exchangeCode.mockResolvedValue( + facebookAdapter.outputs.exchangeCode.noScopes, + ); + + let result; + try { + result = await service.createFromFacebookProvider({ + code: "code", + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(400); + expect(result.message).toBe( + `Missing required scopes: ${facebookAdapter.mock.requiredScopes.join( + " ", + )}`, + ); + expect(facebookAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect( + facebookAdapter.mock.getAuthenticatedUserData, + ).not.toHaveBeenCalled(); + }); + + it("should fail if unverified provider email", async () => { + facebookAdapter.mock.exchangeCode.mockResolvedValue( + facebookAdapter.outputs.exchangeCode.success, + ); + facebookAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + facebookAdapter.outputs.getAuthenticatedUserData.unverified, + ); + + let result; + try { + result = await service.createFromFacebookProvider({ + code: "code", + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(403); + expect(result.message).toBe("Unverified provider email"); + expect(facebookAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(facebookAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).not.toHaveBeenCalled(); + }); + + it("should fail if find account by email related to another facebook account", async () => { + facebookAdapter.mock.exchangeCode.mockResolvedValue( + facebookAdapter.outputs.exchangeCode.success, + ); + facebookAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + facebookAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.sameEmailDifferentFacebook, + ); + + let result; + try { + result = await service.createFromFacebookProvider({ + code: "code", + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(409); + expect(result.message).toBe( + "Error finding account, please contact support", + ); + expect(facebookAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(facebookAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.updateProvider).not.toHaveBeenCalled(); + }); + }); + describe("> createFromEmailProvider", () => { it.todo("should"); }); diff --git a/tsconfig.lint.json b/tsconfig.lint.json index 39ab976..2fb33e7 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -3,6 +3,6 @@ "compilerOptions": { "outDir": "./dist-lint" }, - "include": ["src", "tests", ".eslintrc.js", "commitlint.config.ts"], + "include": ["src", "tests", ".eslintrc.js"], "exclude": ["node_modules", "dist", "dist-lint"] }