├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── charts └── oauth2 │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── ingress.yaml │ ├── secrets.yaml │ ├── service.yaml │ └── serviceaccount.yaml │ └── values.yaml ├── client ├── .gitignore ├── README.md ├── codegen.yml ├── package.json ├── public │ ├── favicon.ico │ ├── form_post.html │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── apollo │ │ ├── client.ts │ │ ├── fragment │ │ │ └── index.ts │ │ ├── mutations │ │ │ ├── client.ts │ │ │ ├── current-user.ts │ │ │ └── user.ts │ │ ├── queries │ │ │ ├── clients.ts │ │ │ ├── current-user.ts │ │ │ ├── dashboard.ts │ │ │ └── users.ts │ │ └── typeDefs.ts │ ├── assets │ │ └── google-logo.svg │ ├── client.ts │ ├── components │ │ ├── authorize.form.tsx │ │ ├── breadcrumb │ │ │ └── index.tsx │ │ ├── clients │ │ │ ├── client-basic-info.tsx │ │ │ ├── client-danger-zone.tsx │ │ │ ├── client-new-dialog.tsx │ │ │ ├── client-new.tsx │ │ │ ├── client-settings.tsx │ │ │ ├── clients-list.tsx │ │ │ └── index.ts │ │ ├── current-user │ │ │ ├── current-user-basic-info-form.tsx │ │ │ ├── current-user-security-form.tsx │ │ │ ├── current-user-sessions.tsx │ │ │ ├── current-user-tfa.tsx │ │ │ ├── index.ts │ │ │ └── tfa-form.tsx │ │ ├── errors │ │ │ ├── base-error.tsx │ │ │ ├── error403.tsx │ │ │ ├── error404.tsx │ │ │ └── index.ts │ │ ├── guest │ │ │ ├── index.ts │ │ │ ├── login-form.tsx │ │ │ ├── register-form.tsx │ │ │ ├── social-logins.tsx │ │ │ ├── styles.ts │ │ │ └── tfa-form.tsx │ │ ├── index.ts │ │ ├── layouts │ │ │ ├── app.layout.tsx │ │ │ ├── guest.layout.tsx │ │ │ └── index.ts │ │ ├── loading │ │ │ └── index.tsx │ │ ├── navigation │ │ │ ├── index.ts │ │ │ ├── list-item-link.tsx │ │ │ ├── navbar.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── styles.ts │ │ │ └── user-profile.tsx │ │ ├── recap-cards │ │ │ └── index.tsx │ │ ├── routes │ │ │ ├── guest.route.tsx │ │ │ ├── index.ts │ │ │ └── protected.route.tsx │ │ └── users │ │ │ ├── index.ts │ │ │ ├── user-danger-zone.tsx │ │ │ ├── user-edit.tsx │ │ │ ├── user-form.tsx │ │ │ ├── user-new.tsx │ │ │ └── users-list.tsx │ ├── generated │ │ └── graphql.tsx │ ├── history.ts │ ├── hooks │ │ ├── index.ts │ │ ├── useActiveSessions.ts │ │ ├── useAppCurrentUser.ts │ │ ├── useAppdata.ts │ │ ├── useAuthorize.ts │ │ ├── useClient.ts │ │ ├── useClientForm.ts │ │ ├── useClients.ts │ │ ├── useCsrf.ts │ │ ├── useCurrentUser.ts │ │ ├── useCurrentUserForm.ts │ │ ├── useDashboard.ts │ │ ├── useLogin.ts │ │ ├── useRegister.ts │ │ ├── useTfaDisable.ts │ │ ├── useTfaForm.ts │ │ ├── useTfaRequest.ts │ │ ├── useUser.ts │ │ ├── useUserForm.ts │ │ └── useUsers.ts │ ├── index.css │ ├── index.tsx │ ├── pages │ │ ├── app.page.tsx │ │ ├── app │ │ │ ├── client.detail.page.tsx │ │ │ ├── clients.page.tsx │ │ │ ├── current-user.page.tsx │ │ │ ├── dashboard.page.tsx │ │ │ ├── user.detail.page.tsx │ │ │ └── users.page.tsx │ │ ├── authorize.page.tsx │ │ ├── home.page.tsx │ │ ├── login.page.tsx │ │ └── register.page.tsx │ ├── react-app-env.d.ts │ ├── serviceWorker.ts │ ├── setupTests.ts │ ├── theme │ │ └── firebase │ │ │ ├── base.ts │ │ │ ├── dark.ts │ │ │ ├── index.ts │ │ │ └── light.ts │ └── utils │ │ ├── async-load.tsx │ │ ├── dummy.ts │ │ ├── enum-maps.tsx │ │ ├── get-client-logo.tsx │ │ ├── index.ts │ │ ├── remove-falsy.ts │ │ └── userCan.ts ├── tsconfig.json ├── views │ ├── form_post.html │ └── index.html └── yarn.lock ├── docker-compose.yml ├── docs ├── .gitignore ├── README.md ├── docs │ ├── api │ │ ├── introduction.md │ │ ├── oauth2 │ │ │ ├── authorization_code.md │ │ │ ├── client_credentials.md │ │ │ ├── introspection.md │ │ │ ├── password.md │ │ │ └── refresh_token.md │ │ ├── openid │ │ │ ├── jwks.md │ │ │ ├── openid-configuration.md │ │ │ └── userinfo.md │ │ └── scopes.md │ ├── client-specification.md │ ├── example │ │ ├── auth_code.md │ │ ├── auth_code_pkce.md │ │ ├── client_credentials.md │ │ ├── password.md │ │ └── refresh_token.md │ └── getting-started.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ └── styles.module.css ├── static │ └── img │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg └── yarn.lock ├── nest-cli.json ├── ormconfig.js ├── package.json ├── schema.graphql ├── src ├── apps │ ├── cli │ │ ├── main.ts │ │ └── modules │ │ │ └── cli │ │ │ ├── cli.module.ts │ │ │ ├── index.ts │ │ │ └── services │ │ │ ├── index.ts │ │ │ └── keys.service.ts │ ├── init │ │ ├── db │ │ │ ├── migrations │ │ │ │ └── 1595928940894-Init.ts │ │ │ ├── seed-runner.ts │ │ │ ├── seeder.interface.ts │ │ │ └── seeds │ │ │ │ ├── admin-user.seeder.ts │ │ │ │ └── index.ts │ │ ├── init.module.ts │ │ └── main.ts │ └── oauth2 │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── entities │ │ ├── base.entity.ts │ │ ├── base.token.ts │ │ ├── index.ts │ │ ├── key.ts │ │ ├── o-auth-access-token.ts │ │ ├── o-auth-client.ts │ │ ├── o-auth-code.ts │ │ ├── o-auth-refresh-token.ts │ │ ├── social-login.entity.ts │ │ └── user.ts │ │ ├── lib │ │ ├── cipher │ │ │ ├── cipher.module.ts │ │ │ ├── constants.ts │ │ │ ├── generators │ │ │ │ ├── index.ts │ │ │ │ └── rs256.generator.ts │ │ │ ├── hash.ts │ │ │ ├── index.ts │ │ │ ├── interfaces │ │ │ │ ├── cipher-module.options.ts │ │ │ │ └── index.ts │ │ │ └── services │ │ │ │ ├── cipher.service.ts │ │ │ │ └── index.ts │ │ ├── jwt │ │ │ ├── index.ts │ │ │ ├── jwt.module.ts │ │ │ └── services │ │ │ │ ├── index.ts │ │ │ │ └── jwt.service.ts │ │ ├── redis │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── interfaces.ts │ │ │ ├── redis-core.module.ts │ │ │ ├── redis.module.ts │ │ │ └── utils.ts │ │ └── sign │ │ │ ├── constants.ts │ │ │ ├── guards │ │ │ ├── index.ts │ │ │ └── signed.guard.ts │ │ │ ├── index.ts │ │ │ ├── interfaces.ts │ │ │ ├── services │ │ │ ├── index.ts │ │ │ └── url-sign.service.ts │ │ │ └── sign.module.ts │ │ ├── main.ts │ │ ├── modules │ │ ├── auth │ │ │ ├── auth.module.ts │ │ │ ├── controllers │ │ │ │ ├── index.ts │ │ │ │ ├── login.controller.ts │ │ │ │ ├── logout.controller.ts │ │ │ │ ├── register.controller.ts │ │ │ │ └── social.controller.ts │ │ │ ├── decorators │ │ │ │ ├── access-token.decorator.ts │ │ │ │ ├── cookie.decorator.ts │ │ │ │ ├── current-user.decorator.ts │ │ │ │ ├── index.ts │ │ │ │ └── scope.decorator.ts │ │ │ ├── dtos │ │ │ │ ├── index.ts │ │ │ │ ├── login.dto.ts │ │ │ │ └── register.dto.ts │ │ │ ├── errors │ │ │ │ ├── guest.exception.ts │ │ │ │ ├── index.ts │ │ │ │ └── tfa-required.exception.ts │ │ │ ├── filters │ │ │ │ ├── forbidden-exception.filter.ts │ │ │ │ ├── guest-exception.filter.ts │ │ │ │ ├── index.ts │ │ │ │ └── tfa-exception.filter.ts │ │ │ ├── guards │ │ │ │ ├── authenticated.guard.ts │ │ │ │ ├── guest.guard.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.guard.ts │ │ │ │ ├── login.guard.ts │ │ │ │ ├── scope.guard.ts │ │ │ │ └── tfa.guard.ts │ │ │ ├── index.ts │ │ │ ├── interfaces.ts │ │ │ ├── roles │ │ │ │ └── index.ts │ │ │ ├── serializers │ │ │ │ ├── index.ts │ │ │ │ └── session.serializer.ts │ │ │ ├── strategies │ │ │ │ ├── facebook.strategy.ts │ │ │ │ ├── google.strategy.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.strategy.ts │ │ │ │ ├── local.strategy.ts │ │ │ │ └── tfa.strategy.ts │ │ │ └── utils │ │ │ │ └── index.ts │ │ ├── mail │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── mail-options.factory.ts │ │ │ ├── mail.module.ts │ │ │ ├── modules │ │ │ │ └── adapter │ │ │ │ │ ├── adapter.module.ts │ │ │ │ │ ├── handlebars.adapter.ts │ │ │ │ │ └── index.ts │ │ │ ├── processors │ │ │ │ └── mail.processor.ts │ │ │ └── services │ │ │ │ ├── index.ts │ │ │ │ └── mail.service.ts │ │ ├── management-api │ │ │ ├── filters │ │ │ │ ├── graphql.filter.ts │ │ │ │ ├── http-exception.filter.ts │ │ │ │ └── index.ts │ │ │ ├── graphql.factory.ts │ │ │ ├── guards │ │ │ │ ├── ac.guard.ts │ │ │ │ ├── auth.guard.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── inputs │ │ │ │ ├── client-meta.input.ts │ │ │ │ ├── create-client.input.ts │ │ │ │ ├── create-user.input.ts │ │ │ │ ├── index.ts │ │ │ │ ├── update-client.input.ts │ │ │ │ ├── update-current-user.input.ts │ │ │ │ └── update-user.input.ts │ │ │ ├── management-api.module.ts │ │ │ ├── resolvers │ │ │ │ ├── client.resolver.ts │ │ │ │ ├── current-user.resolver.ts │ │ │ │ ├── dashboard.resolver.ts │ │ │ │ ├── index.ts │ │ │ │ └── user.resolver.ts │ │ │ ├── services │ │ │ │ ├── index.ts │ │ │ │ ├── session.service.ts │ │ │ │ └── tfa.service.ts │ │ │ └── types │ │ │ │ ├── index.ts │ │ │ │ ├── pagination-info.ts │ │ │ │ ├── session.ts │ │ │ │ └── users-paginated.response.ts │ │ ├── oauth2 │ │ │ ├── auth.request.ts │ │ │ ├── constants.ts │ │ │ ├── controllers │ │ │ │ ├── authorize.controller.ts │ │ │ │ ├── debug.controller.ts │ │ │ │ ├── index.ts │ │ │ │ └── token.controller.ts │ │ │ ├── dto │ │ │ │ ├── authorize.dto.ts │ │ │ │ ├── consent.dto.ts │ │ │ │ ├── index.ts │ │ │ │ ├── introspect.dto.ts │ │ │ │ └── token.dto.ts │ │ │ ├── errors │ │ │ │ ├── index.ts │ │ │ │ └── o-auth.exception.ts │ │ │ ├── filters │ │ │ │ ├── RFC6749-exception.filter.ts │ │ │ │ ├── authorize-forbidden-exception.filter.ts │ │ │ │ ├── index.ts │ │ │ │ └── o-auth-exception.filter.ts │ │ │ ├── guards │ │ │ │ ├── authorize.guard.ts │ │ │ │ ├── client-auth.guard.ts │ │ │ │ ├── index.ts │ │ │ │ └── pkce.guard.ts │ │ │ ├── index.ts │ │ │ ├── interfaces │ │ │ │ └── index.ts │ │ │ ├── modules │ │ │ │ ├── authorization-code │ │ │ │ │ ├── authorization-code.module.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── services │ │ │ │ │ │ ├── auth-code.service.ts │ │ │ │ │ │ ├── authorization-code.service-grant.ts │ │ │ │ │ │ └── index.ts │ │ │ │ ├── client-credentials │ │ │ │ │ ├── client-credentials.module.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── services │ │ │ │ │ │ ├── client-credentials.service-grant.ts │ │ │ │ │ │ └── index.ts │ │ │ │ ├── common │ │ │ │ │ ├── abstract.grant.ts │ │ │ │ │ ├── common.module.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── decorators │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── injectable.grant.decorator.ts │ │ │ │ │ ├── grant.interface.ts │ │ │ │ │ ├── grant.scanner.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── services │ │ │ │ │ │ ├── access-token.service.ts │ │ │ │ │ │ ├── base-token.service.ts │ │ │ │ │ │ ├── client.service.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── refresh-token.service.ts │ │ │ │ │ └── token │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── strategies │ │ │ │ │ │ ├── aes.strategy.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── jwt.strategy.ts │ │ │ │ │ │ └── strategy.ts │ │ │ │ ├── index.ts │ │ │ │ ├── password │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── password.module.ts │ │ │ │ │ └── services │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── password.service-grant.ts │ │ │ │ └── refresh-token │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── refresh-token.module.ts │ │ │ │ │ └── services │ │ │ │ │ ├── index.ts │ │ │ │ │ └── refresh-token.service-grant.ts │ │ │ ├── oauth2.module.ts │ │ │ ├── services │ │ │ │ ├── code.service.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth.service.ts │ │ │ │ └── token.service.ts │ │ │ └── utils │ │ │ │ └── index.ts │ │ ├── openid │ │ │ ├── controllers │ │ │ │ ├── index.ts │ │ │ │ ├── user-info.controller.ts │ │ │ │ └── well-known.controller.ts │ │ │ ├── index.ts │ │ │ ├── open-id-config.ts │ │ │ ├── open-id.module.ts │ │ │ └── services │ │ │ │ ├── index.ts │ │ │ │ └── open-id.service.ts │ │ ├── user-api │ │ │ ├── controllers │ │ │ │ ├── index.ts │ │ │ │ └── user-api.controller.ts │ │ │ └── user-api.module.ts │ │ └── user │ │ │ ├── controllers │ │ │ ├── email.controller.ts │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── services │ │ │ ├── index.ts │ │ │ ├── password.service.ts │ │ │ ├── register.service.ts │ │ │ └── user.service.ts │ │ │ ├── test.controller.ts │ │ │ └── user.module.ts │ │ ├── request.d.ts │ │ └── utils │ │ ├── confirm.validator.ts │ │ ├── date.ts │ │ ├── index.ts │ │ └── url.ts ├── config │ ├── app.ts │ ├── cert.ts │ ├── crypto.ts │ ├── db.ts │ ├── index.ts │ ├── jwt.ts │ ├── mail.ts │ ├── management.ts │ ├── oauth.ts │ ├── rateLimit.ts │ ├── redis.ts │ └── social.ts └── main.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json ├── views ├── layouts │ └── mail.hbs ├── mail │ └── welcome.hbs └── partials │ └── .gitkeep └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | PORT=4000 2 | 3 | APP_URL=http://localhost:4000/ 4 | 5 | DB_HOST=localhost 6 | DB_PORT=5432 7 | DB_NAME=argo 8 | DB_USERNAME=argo 9 | DB_PASSWORD=secret 10 | 11 | REDIS_HOST=localhost 12 | REDIS_PORT=6380 13 | 14 | #MAIL_USERNAME= 15 | #MAIL_PASSWORD= 16 | MAIL_HOST=localhost 17 | MAIL_PORT=1025 18 | MAIL_SKIP_TLS=true 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | /db-data 37 | .env 38 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine as clientBuilder 2 | 3 | COPY client/package.json . 4 | COPY client/yarn.lock . 5 | 6 | COPY client/tsconfig.json . 7 | 8 | COPY client/views views 9 | COPY client/public public 10 | COPY client/src src 11 | 12 | RUN yarn install 13 | 14 | RUN yarn build 15 | 16 | FROM node:lts-alpine as builder 17 | 18 | RUN apk add python make g++ 19 | 20 | COPY package.json . 21 | COPY yarn.lock . 22 | 23 | RUN yarn install 24 | 25 | COPY nest-cli.json . 26 | COPY tsconfig.json . 27 | COPY tsconfig.build.json . 28 | 29 | COPY src src 30 | 31 | ENV NODE_ENV=production 32 | 33 | RUN yarn build 34 | 35 | FROM node:lts-alpine as prod 36 | 37 | WORKDIR /app 38 | 39 | COPY --from=builder package.json . 40 | COPY --from=builder yarn.lock . 41 | 42 | ENV NODE_ENV=production 43 | 44 | RUN yarn install --prod --frozen-lockfile --ignore-optional && \ 45 | rm -rf node_modules/@types && \ 46 | yarn cache clean 47 | 48 | COPY ormconfig.js . 49 | 50 | COPY --from=builder dist dist 51 | 52 | COPY --from=clientBuilder build client/build 53 | COPY --from=clientBuilder views client/views 54 | 55 | COPY views views 56 | 57 | ENV PATH /app/node_modules/.bin:$PATH 58 | 59 | EXPOSE 5000 60 | 61 | ENTRYPOINT ["node", "dist/main.js"] 62 | 63 | CMD ["serve"] 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | ## OAuth2 Server 6 | 7 | WIP 8 | 9 | ### TODO 10 | 11 | * Non JWT Access token 12 | * Documentation 13 | * Comments 14 | 15 | -------------------------------------------------------------------------------- /charts/oauth2/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /charts/oauth2/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: oauth2 3 | type: application 4 | 5 | # This is the chart version. This version number should be incremented each time you make changes 6 | # to the chart and its templates, including the app version. 7 | version: 0.2.0 8 | 9 | # This is the version number of the application being deployed. This version number should be 10 | # incremented each time you make changes to the application. 11 | appVersion: 1.0.0 12 | -------------------------------------------------------------------------------- /charts/oauth2/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "oauth2.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "oauth2.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "oauth2.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "oauth2.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | echo "Visit http://127.0.0.1:8080 to use your application" 20 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /charts/oauth2/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | apiVersion: traefik.containo.us/v1alpha1 3 | kind: IngressRoute 4 | metadata: 5 | name: {{ include "oauth2.fullname" . }} 6 | annotations: 7 | helm.sh/hook: "post-install,post-upgrade" 8 | {{- with .Values.ingress.annotations }} 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | labels: 12 | {{- include "oauth2.labels" . | nindent 4 }} 13 | spec: 14 | entryPoints: 15 | - web 16 | routes: 17 | - match: {{ .Values.ingress.match }} 18 | kind: Rule 19 | services: 20 | - kind: Service 21 | name: {{ include "oauth2.fullname" . }} 22 | passHostHeader: true 23 | port: 80 24 | responseForwarding: 25 | flushInterval: 1ms 26 | strategy: RoundRobin 27 | weight: 10 28 | {{- end -}} 29 | -------------------------------------------------------------------------------- /charts/oauth2/templates/secrets.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | stringData: 3 | APP_URL: {{ .Values.appUrl }} 4 | {{- with .Values.database }} 5 | DB_HOST: {{ .host | quote }} 6 | DB_PORT: {{ .port | quote }} 7 | DB_NAME: {{ .name | quote }} 8 | DB_USERNAME: {{ .username | quote }} 9 | DB_PASSWORD: {{ .password | quote }} 10 | {{- end }} 11 | {{- with .Values.redis }} 12 | REDIS_HOST: {{ .host | quote }} 13 | REDIS_PORT: {{ .port | quote }} 14 | {{- end }} 15 | {{- with .Values.crypto }} 16 | APP_IV: {{ .iv | quote }} 17 | APP_SECRET: {{ .secret | quote }} 18 | {{- end }} 19 | {{- with .Values.facebook }} 20 | FACEBOOK_ID: {{ .id | quote }} 21 | FACEBOOK_SECRET: {{ .secret | quote }} 22 | {{- end }} 23 | {{- with .Values.google }} 24 | GOOGLE_ID: {{ .id | quote }} 25 | GOOGLE_SECRET: {{ .secret | quote }} 26 | {{- end }} 27 | kind: Secret 28 | metadata: 29 | name: {{ include "oauth2.fullname" . }}-env 30 | labels: 31 | {{- include "oauth2.labels" . | nindent 4 }} 32 | type: Opaque 33 | -------------------------------------------------------------------------------- /charts/oauth2/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "oauth2.fullname" . }} 5 | labels: 6 | {{- include "oauth2.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "oauth2.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /charts/oauth2/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "oauth2.serviceAccountName" . }} 6 | labels: 7 | {{- include "oauth2.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end -}} 13 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 3 | - "../schema.graphql" 4 | - "./src/apollo/typeDefs.ts" 5 | documents: 6 | - "src/**/*.{ts,tsx}" 7 | generates: 8 | src/generated/graphql.tsx: 9 | plugins: 10 | - add: '/* eslint-disable */' 11 | - "typescript" 12 | - "typescript-operations" 13 | - "typescript-react-apollo" 14 | - "fragment-matcher" 15 | config: 16 | gqlImport: "@apollo/client#gql" 17 | withHOC: false 18 | withHooks: false 19 | withMutationFn: false 20 | withComponent: false 21 | apolloReactCommonImportFrom: "@apollo/client" 22 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/form_post.html: -------------------------------------------------------------------------------- 1 | 2 | Submit 3 | 4 |
5 | {{#each hiddenFields}} 6 | 7 | {{/each}} 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { Route, Router, Switch } from 'react-router-dom'; 3 | import { ClientContextProvider } from 'react-fetching-library'; 4 | import { history } from './history'; 5 | import HomePage from './pages/home.page'; 6 | import { client } from './client'; 7 | import { GuestRoute } from './components/routes'; 8 | import { asyncLoad } from './utils'; 9 | import { Loading } from './components/loading'; 10 | 11 | const AsyncLogin = asyncLoad(() => import('./pages/login.page')); 12 | const AsyncRegister = asyncLoad(() => import('./pages/register.page')); 13 | const AsyncAuthorize = asyncLoad(() => import('./pages/authorize.page')); 14 | const AsyncApp = asyncLoad(() => import('./pages/app.page')); 15 | 16 | export const App: React.FC = () => { 17 | return ( 18 | 19 | 20 | }> 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /client/src/apollo/fragment/index.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | fragment ClientData on OAuthClient { 5 | id 6 | name 7 | secret 8 | redirect 9 | meta 10 | createdAt 11 | updatedAt 12 | grantTypes 13 | authMethods 14 | responseModes 15 | responseTypes 16 | scopes 17 | firstParty 18 | } 19 | `; 20 | 21 | gql` 22 | fragment UserData on User { 23 | id 24 | nickname 25 | firstName 26 | lastName 27 | email 28 | picture 29 | createdAt 30 | updatedAt 31 | emailVerifiedAt 32 | role 33 | tfaEnabled 34 | } 35 | `; 36 | 37 | gql` 38 | fragment SessionData on Session { 39 | sessionId 40 | ip 41 | browser 42 | os 43 | createdAt 44 | userAgent 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /client/src/apollo/mutations/client.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | mutation CreateClient($data: CreateClientInput!) { 5 | createClient(data: $data) { 6 | ...ClientData 7 | } 8 | } 9 | `; 10 | 11 | gql` 12 | mutation UpdateClient($id: ID!, $data: UpdateClientInput!) { 13 | updateClient(id: $id, data: $data) { 14 | ...ClientData 15 | } 16 | } 17 | `; 18 | 19 | gql` 20 | mutation DeleteClient($id: ID!) { 21 | deleteClient(id: $id) 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /client/src/apollo/mutations/current-user.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | mutation UpdateCurrentUser($data: UpdateCurrentUserInput!) { 5 | updateCurrentUser(data: $data) { 6 | ...UserData 7 | } 8 | } 9 | `; 10 | 11 | gql` 12 | mutation DeleteSession($id: ID!) { 13 | deleteSession(id: $id) 14 | } 15 | `; 16 | 17 | gql` 18 | mutation RequestTfa { 19 | requestTfa 20 | } 21 | `; 22 | 23 | gql` 24 | mutation VerifyTfa($code: String!) { 25 | verifyTfa(code: $code) 26 | } 27 | `; 28 | 29 | gql` 30 | mutation DisableTfa { 31 | disableTfa 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /client/src/apollo/mutations/user.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | mutation CreateUser($data: CreateUserInput!) { 5 | createUser(data: $data) { 6 | ...UserData 7 | } 8 | } 9 | `; 10 | 11 | gql` 12 | mutation UpdateUser($id: ID!, $data: UpdateUserInput!) { 13 | updateUser(id: $id, data: $data) { 14 | ...UserData 15 | } 16 | } 17 | `; 18 | 19 | gql` 20 | mutation DeleteUser($id: ID!) { 21 | deleteUser(id: $id) 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /client/src/apollo/queries/clients.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | query GetClients { 5 | getClients { 6 | ...ClientData 7 | } 8 | } 9 | `; 10 | 11 | gql` 12 | query GetClient($id: ID!) { 13 | getClient(id: $id) { 14 | ...ClientData 15 | } 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /client/src/apollo/queries/current-user.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | query GetCurrentUser { 5 | getCurrentUser { 6 | ...UserData 7 | } 8 | } 9 | `; 10 | 11 | 12 | gql` 13 | query GetActiveSessions { 14 | activeSessions { 15 | ...SessionData 16 | } 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /client/src/apollo/queries/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | query GetDashboardInfo($since: DateTime!) { 5 | clientsCount 6 | usersCount 7 | newSignUps(since: $since) 8 | getUsers(limit: 5) { 9 | items { 10 | ...UserData 11 | } 12 | } 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /client/src/apollo/queries/users.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | gql` 4 | query GetUsers( 5 | $skip: Int! 6 | $limit: Int! 7 | ) { 8 | getUsers(skip: $skip, limit: $limit) { 9 | items { 10 | ...UserData 11 | } 12 | paginationInfo { 13 | total 14 | hasMore 15 | } 16 | } 17 | } 18 | `; 19 | 20 | gql` 21 | query GetUser($id: ID!) { 22 | getUser(id: $id) { 23 | ...UserData 24 | } 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /client/src/apollo/typeDefs.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const typeDefs = gql` 4 | directive @client on FIELD 5 | extend type Query { 6 | getCurrentUser: User 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /client/src/assets/google-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/client.ts: -------------------------------------------------------------------------------- 1 | import { createClient, RequestInterceptor } from 'react-fetching-library'; 2 | 3 | const XSRFToken = document.head.querySelector('meta[name="csrf-token"]'); 4 | 5 | if (!XSRFToken) { 6 | console.error('CSRF token not found'); 7 | } 8 | 9 | const XSRFInterceptor: RequestInterceptor = client => async action => { 10 | return { 11 | ...action, 12 | headers: { 13 | ...action.headers, 14 | 'X-CSRF-TOKEN': XSRFToken!.getAttribute('content')!, 15 | } 16 | } 17 | } 18 | 19 | export const client = createClient({ 20 | requestInterceptors: [XSRFInterceptor], 21 | responseInterceptors: [], 22 | }); 23 | -------------------------------------------------------------------------------- /client/src/components/breadcrumb/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect, useState } from 'react'; 2 | import { useLocation, Link as RouterLink } from 'react-router-dom'; 3 | import { Box, Typography, Breadcrumbs, Link } from '@material-ui/core'; 4 | import { capitalize } from '../../utils'; 5 | 6 | const uuidRegex = new RegExp(/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-5][0-9a-f]{3}-?[089ab][0-9a-f]{3}-?[0-9a-f]{12}$/i); 7 | 8 | export const Breadcrumb = memo(() => { 9 | const location = useLocation(); 10 | const [crumbs, setCrumbs] = useState([]); 11 | 12 | useEffect(() => { 13 | const split = location.pathname.split('/').filter(Boolean); 14 | 15 | const crumbs = split.map((s, idx) => { 16 | let prev = '/' + split.slice(0, idx).join('/'); 17 | 18 | if (prev.endsWith('/')) { 19 | prev = prev.slice(0, -1); 20 | } 21 | 22 | return { 23 | label: uuidRegex.test(s) ? 'Details' : capitalize(s), 24 | ...(idx < (split.length - 1)) && { 25 | path: `${prev}/${s}`, 26 | }, 27 | } 28 | }); 29 | 30 | setCrumbs(crumbs.length > 1 ? crumbs : []); 31 | 32 | }, [location]); 33 | 34 | return ( 35 | 36 | 37 | {crumbs.map(crumb => ( 38 | crumb.path ? ( 39 | 40 | {crumb.label} 41 | 42 | ) : ( 43 | {crumb.label} 44 | ) 45 | ))} 46 | 47 | 48 | ) 49 | }); 50 | -------------------------------------------------------------------------------- /client/src/components/clients/client-new-dialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dialog, DialogContent, DialogProps, DialogTitle } from '@material-ui/core'; 3 | import { ClientNew } from './client-new'; 4 | 5 | export interface ClientNewDialogProps extends DialogProps { 6 | onClose: () => void; 7 | onSubmit?: () => void; 8 | } 9 | 10 | export const ClientNewDialog: React.FC = ({ onSubmit, ...props }) => { 11 | return ( 12 | 13 | 14 | Create a new Client 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /client/src/components/clients/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clients-list'; 2 | export * from './client-basic-info'; 3 | export * from './client-settings'; 4 | export * from './client-danger-zone'; 5 | export * from './client-new'; 6 | export * from './client-new-dialog'; 7 | -------------------------------------------------------------------------------- /client/src/components/current-user/current-user-tfa.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useAppCurrentUser, useTfaDisable } from '../../hooks'; 3 | import { Button, Dialog, DialogTitle, DialogContent } from '@material-ui/core'; 4 | import { TfaForm } from './tfa-form'; 5 | 6 | export const CurrentUserTfa: React.FC = () => { 7 | const user = useAppCurrentUser(); 8 | const [open, setOpen] = useState(false); 9 | const { disableTfa, loading } = useTfaDisable(); 10 | 11 | if (!user) { 12 | return null; 13 | } 14 | 15 | return ( 16 | <> 17 | {user.tfaEnabled ? ( 18 | 26 | ) : ( 27 | 28 | )} 29 | setOpen(false)}> 30 | Setup Two factor authentication 31 | 32 | {open && setOpen(false)}/>} 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /client/src/components/current-user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './current-user-basic-info-form'; 2 | export * from './current-user-security-form'; 3 | export * from './current-user-sessions'; 4 | -------------------------------------------------------------------------------- /client/src/components/errors/base-error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Typography } from '@material-ui/core'; 3 | 4 | export const BaseError: React.FC<{ message: string; code: number }> = ({ message, code }) => { 5 | return ( 6 | 14 | {code} 15 | 16 | {message} 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /client/src/components/errors/error403.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseError } from './base-error'; 3 | 4 | export const Error403: React.FC<{ message?: string }> = ({ message = 'You don\'t have permissions to access this page' }) => { 5 | return ( 6 | 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /client/src/components/errors/error404.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseError } from './base-error'; 3 | 4 | export const Error404: React.FC<{ message?: string }> = ({ message = 'Page not found' }) => { 5 | return ( 6 | 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /client/src/components/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error404'; 2 | export * from './error403'; 3 | -------------------------------------------------------------------------------- /client/src/components/guest/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login-form'; 2 | export * from './register-form'; 3 | -------------------------------------------------------------------------------- /client/src/components/guest/styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | 3 | export const useGuestStyles = makeStyles(theme => ({ 4 | form: { 5 | width: '100%', // Fix IE 11 issue. 6 | marginTop: theme.spacing(1), 7 | }, 8 | submit: { 9 | margin: theme.spacing(3, 0, 2), 10 | }, 11 | marginLeft: { 12 | marginLeft: 'auto', 13 | }, 14 | })); 15 | -------------------------------------------------------------------------------- /client/src/components/guest/tfa-form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography, TextField, Button } from '@material-ui/core'; 3 | import { useGuestStyles } from './styles'; 4 | import { useTfaForm } from '../../hooks'; 5 | 6 | export const TfaForm: React.FC<{ remember?: boolean }> = ({ remember = false }) => { 7 | const classes = useGuestStyles(); 8 | const { onSubmit, register, errors, loading } = useTfaForm(remember); 9 | 10 | return ( 11 |
12 | 13 | Insert the Time based Code from your two factor authentication app 14 | 15 | 27 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /client/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './layouts'; 2 | export * from './authorize.form'; 3 | export * from './guest'; 4 | export * from './navigation'; 5 | export * from './routes'; 6 | export * from './clients'; 7 | export * from './users'; 8 | export * from './errors'; 9 | export * from './loading'; 10 | export * from './current-user'; 11 | export * from './recap-cards'; 12 | -------------------------------------------------------------------------------- /client/src/components/layouts/app.layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, PropsWithChildren, useCallback, useState } from 'react'; 2 | import { makeStyles, Theme } from '@material-ui/core/styles'; 3 | import { NavBar, SideBar } from '../navigation'; 4 | import { Breadcrumb } from '../breadcrumb'; 5 | 6 | const useStyles = makeStyles((theme: Theme) => ({ 7 | root: { 8 | display: 'flex', 9 | }, 10 | content: { 11 | flexGrow: 1, 12 | padding: theme.spacing(3), 13 | paddingTop: theme.spacing(2), 14 | }, 15 | toolbar: theme.mixins.toolbar, 16 | })); 17 | 18 | export const AppLayout = memo>(({ hasSidebar, children }) => { 19 | const classes = useStyles(); 20 | const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); 21 | 22 | const toggleDrawer = useCallback(() => { 23 | setMobileDrawerOpen(v => !v); 24 | }, [setMobileDrawerOpen]); 25 | 26 | const closeDrawer = () => setMobileDrawerOpen(false); 27 | 28 | return ( 29 |
30 | 31 | {hasSidebar && } 32 |
33 |
34 | 35 | {children} 36 |
37 |
38 | ) 39 | }); 40 | -------------------------------------------------------------------------------- /client/src/components/layouts/guest.layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Avatar, Container } from '@material-ui/core'; 3 | import { LockOutlined } from '@material-ui/icons'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | 6 | const useStyles = makeStyles(theme => ({ 7 | root: { 8 | marginTop: theme.spacing(8), 9 | display: 'flex', 10 | flexDirection: 'column', 11 | alignItems: 'center', 12 | }, 13 | avatar: { 14 | margin: theme.spacing(1), 15 | backgroundColor: theme.palette.secondary.main, 16 | }, 17 | })); 18 | 19 | export const GuestLayout: React.FC = ({ children }) => { 20 | const classes = useStyles(); 21 | 22 | return ( 23 | 24 |
25 | 26 | 27 | 28 | {children} 29 |
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /client/src/components/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './guest.layout'; 2 | export * from './app.layout'; 3 | -------------------------------------------------------------------------------- /client/src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, CircularProgress, Typography } from '@material-ui/core'; 3 | 4 | export interface LoadingProps { 5 | message?: string; 6 | } 7 | 8 | export const Loading: React.FC = ({ message = 'Loading...' }) => { 9 | 10 | return ( 11 | 19 | 20 | 21 | 22 | 23 | 24 | {message} 25 | 26 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/navigation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './styles'; 2 | export * from './navbar'; 3 | export * from './sidebar'; 4 | export * from './list-item-link'; 5 | -------------------------------------------------------------------------------- /client/src/components/navigation/list-item-link.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useMemo } from 'react'; 2 | import { ListItem, ListItemProps } from '@material-ui/core'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | export interface ListItemLinkProps extends ListItemProps { 6 | to: string | { 7 | state: any; 8 | pathname: string; 9 | }; 10 | linkComponent?: React.ElementType; 11 | } 12 | 13 | export const ListItemLink = forwardRef(({ to, children, linkComponent: LinkComponent = Link, ...props }, ref) => { 14 | const renderLink = useMemo(() => ( 15 | forwardRef((itemProps, ref) => ) 16 | ), [to]); 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | }); 24 | 25 | ListItemLink.displayName = 'ListItemLink'; 26 | -------------------------------------------------------------------------------- /client/src/components/navigation/styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | 3 | export const drawerWidth = 240; 4 | 5 | export const useNavigationStyles = makeStyles(theme => ({ 6 | appBar: { 7 | zIndex: theme.zIndex.drawer + 1, 8 | }, 9 | drawer: { 10 | [theme.breakpoints.up('sm')]: { 11 | width: drawerWidth, 12 | flexShrink: 0, 13 | }, 14 | }, 15 | drawerPaper: { 16 | width: drawerWidth, 17 | }, 18 | drawerContainer: { 19 | overflow: 'auto', 20 | color: 'rgba(255, 255, 255, 0.7)', 21 | }, 22 | menuButton: { 23 | marginRight: theme.spacing(2), 24 | }, 25 | avatarSmall: { 26 | width: theme.spacing(4), 27 | height: theme.spacing(4), 28 | marginRight: theme.spacing(1), 29 | }, 30 | profileButton: { 31 | textTransform: 'none', 32 | }, 33 | gutters: theme.mixins.gutters(), 34 | linkItem: { 35 | paddingTop: theme.spacing(1), 36 | paddingBottom: theme.spacing(1), 37 | ...(theme.mixins as any).drawerItem, 38 | }, 39 | })) 40 | -------------------------------------------------------------------------------- /client/src/components/navigation/user-profile.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { useAppCurrentUser } from '../../hooks'; 3 | import { Box, Avatar, Typography, BoxProps } from '@material-ui/core'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | 6 | const useStyles = makeStyles(theme => ({ 7 | avatar: { 8 | width: theme.spacing(6), 9 | height: theme.spacing(6), 10 | marginBottom: theme.spacing(2), 11 | } 12 | })); 13 | 14 | export const UserProfile = forwardRef((props, ref) => { 15 | const user = useAppCurrentUser(); 16 | const classes = useStyles(); 17 | 18 | return ( 19 | 27 | 28 | 29 | {user?.nickname} 30 | 31 | 32 | {user?.email} 33 | 34 | 35 | ) 36 | }); 37 | -------------------------------------------------------------------------------- /client/src/components/recap-cards/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Avatar, Card, CardHeader } from '@material-ui/core'; 3 | 4 | export interface RecapCardProps { 5 | icon: React.ReactNode; 6 | count: number; 7 | label: string; 8 | } 9 | 10 | export const RecapCard: React.FC = ({ icon, count, label }) => { 11 | return ( 12 | 13 | 18 | {icon} 19 | 20 | } 21 | /> 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /client/src/components/routes/guest.route.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { Redirect, Route, RouteProps } from 'react-router'; 3 | import { useCurrentUser } from '../../hooks'; 4 | 5 | export const GuestRoute = forwardRef(({ redirect = '/', component, ...rest }, ref) => { 6 | const user = useCurrentUser(); 7 | 8 | const Comp: any = component; 9 | 10 | return { 11 | if (user) { 12 | return 13 | } 14 | return 15 | }}/> 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/components/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './guest.route'; 2 | export * from './protected.route'; 3 | -------------------------------------------------------------------------------- /client/src/components/routes/protected.route.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { Redirect, Route, RouteProps } from 'react-router'; 3 | import { Perms, userCan } from '../../utils'; 4 | 5 | export const ProtectedRoute = forwardRef(({ resource, perm, component, ...rest }, ref) => { 6 | const can = userCan(resource, perm); 7 | 8 | const Comp: any = component; 9 | 10 | return { 11 | if (!can) { 12 | return 13 | } 14 | return 15 | }}/> 16 | }) 17 | -------------------------------------------------------------------------------- /client/src/components/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './users-list'; 2 | export * from './user-new'; 3 | export * from './user-edit'; 4 | export * from './user-danger-zone'; 5 | -------------------------------------------------------------------------------- /client/src/components/users/user-edit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { UserDataFragment } from '../../generated/graphql'; 3 | import { UserForm } from './user-form'; 4 | import { useUserUpdateForm } from '../../hooks'; 5 | 6 | export const UserEdit: React.FC<{ user: UserDataFragment }> = ({ user }) => { 7 | const { onSubmit, loading, watch, errors, register, control } = useUserUpdateForm(user); 8 | 9 | return ( 10 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /client/src/components/users/user-new.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useUserNewForm } from '../../hooks'; 3 | import { UserForm } from './user-form'; 4 | 5 | export const UserNew: React.FC<{ onSubmit?: () => void }> = ({ onSubmit: onParentSubmit }) => { 6 | const { onSubmit, register, errors, watch, loading, control } = useUserNewForm(); 7 | 8 | const onFormSubmit = useCallback(async (e: React.FormEvent) => { 9 | await onSubmit(e); 10 | if (onParentSubmit) { 11 | onParentSubmit(); 12 | } 13 | }, [onSubmit, onParentSubmit]); 14 | 15 | return ( 16 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /client/src/history.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory, Location } from 'history'; 2 | import { parse } from 'query-string'; 3 | 4 | export const history = createBrowserHistory({ 5 | basename: process.env.PUBLIC_URL, 6 | }); 7 | 8 | function parseSearch(location: Location) { 9 | location.query = parse(location.search); 10 | } 11 | 12 | history.listen(parseSearch); 13 | parseSearch(history.location); 14 | -------------------------------------------------------------------------------- /client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | export * from './useAppdata'; 5 | export * from './useLogin'; 6 | export * from './useRegister'; 7 | export * from './useAuthorize'; 8 | export * from './useCsrf'; 9 | export * from './useTfaForm'; 10 | 11 | export * from './useCurrentUser'; 12 | 13 | export * from './useClients'; 14 | export * from './useClient'; 15 | export * from './useClientForm'; 16 | export * from './useUsers'; 17 | export * from './useUserForm'; 18 | export * from './useUser'; 19 | export * from './useCurrentUserForm'; 20 | export * from './useAppCurrentUser'; 21 | export * from './useActiveSessions'; 22 | export * from './useTfaRequest'; 23 | export * from './useTfaDisable'; 24 | 25 | export * from './useDashboard'; 26 | -------------------------------------------------------------------------------- /client/src/hooks/useAppCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GetCurrentUserDocument, GetCurrentUserQuery } from '../generated/graphql'; 3 | 4 | export const useAppCurrentUser = () => { 5 | const { data } = useQuery(GetCurrentUserDocument, { 6 | fetchPolicy: 'cache-only', 7 | }); 8 | return data?.getCurrentUser; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/hooks/useAppdata.ts: -------------------------------------------------------------------------------- 1 | import { dummyGrants, dummySessions, dummyUser } from '../utils/dummy'; 2 | 3 | export const useAppData = (): typeof window.__APP_DATA__ => { 4 | if (process.env.NODE_ENV === 'development') { 5 | return { 6 | user: dummyUser, 7 | currentSession: dummySessions[0].sessionId, 8 | grants: dummyGrants, 9 | appName: 'App name', 10 | } as typeof window.__APP_DATA__; 11 | } 12 | return window.__APP_DATA__; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/hooks/useAuthorize.ts: -------------------------------------------------------------------------------- 1 | import { useAppData } from './useAppdata'; 2 | 3 | export const useAuthorize = () => { 4 | const { client, scopes } = useAppData(); 5 | 6 | return { 7 | client, 8 | scopes, 9 | parsedScopes: scopes.map((scope: string) => ({ 10 | scope, 11 | label: scopeMapping[scope] || undefined, 12 | })) as { scope: string; label?: string }[], 13 | } 14 | } 15 | 16 | const scopeMapping: Record = { 17 | openid: 'Access to your profile basic info', 18 | email: 'Read your email address', 19 | profile: 'Read your name, nickname and profile image', 20 | offline_access: undefined, // TODO what to ask? 21 | }; 22 | -------------------------------------------------------------------------------- /client/src/hooks/useClient.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GetClientDocument, GetClientQuery, GetClientQueryVariables } from '../generated/graphql'; 3 | 4 | export const useClient = (id: string) => { 5 | const { data, error, loading } = useQuery(GetClientDocument, { 6 | variables: { id }, 7 | skip: !id, 8 | }); 9 | 10 | return { 11 | client: data?.getClient, 12 | loading, 13 | error, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/hooks/useClients.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GetClientsDocument, GetClientsQuery } from '../generated/graphql'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | export const useClients = () => { 6 | const { data, loading, error } = useQuery(GetClientsDocument); 7 | const [errorCode, setErrorCode] = useState(''); 8 | 9 | useEffect(() => { 10 | if (error && error.graphQLErrors && error.graphQLErrors.length) { 11 | const firstError = error.graphQLErrors[0]; 12 | if (firstError.extensions && firstError.extensions.code) { 13 | setErrorCode(firstError.extensions.code); 14 | } 15 | } 16 | }, [error]); 17 | 18 | return { 19 | clients: (data?.getClients || []), 20 | loading, 21 | error, 22 | errorCode: errorCode, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/hooks/useCsrf.ts: -------------------------------------------------------------------------------- 1 | import { useAppData } from './useAppdata'; 2 | 3 | export const useCsrf = () => useAppData()?.csrfToken; 4 | -------------------------------------------------------------------------------- /client/src/hooks/useCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { useAppData } from './useAppdata'; 2 | 3 | export const useCurrentUser = () => { 4 | const { user } = useAppData(); 5 | 6 | return user; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/hooks/useDashboard.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GetDashboardInfoDocument, GetDashboardInfoQuery, GetDashboardInfoQueryVariables } from '../generated/graphql'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | const sevenDaysAgo = new Date(); 6 | sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); 7 | 8 | export const useDashboard = () => { 9 | const { data, loading, error } = useQuery(GetDashboardInfoDocument, { 10 | variables: { 11 | since: sevenDaysAgo, 12 | }, 13 | fetchPolicy: 'network-only', 14 | }); 15 | const [errorCode, setErrorCode] = useState(''); 16 | 17 | useEffect(() => { 18 | if (error && error.graphQLErrors && error.graphQLErrors.length) { 19 | const firstError = error.graphQLErrors[0]; 20 | if (firstError.extensions && firstError.extensions.code) { 21 | setErrorCode(firstError.extensions.code); 22 | } 23 | } 24 | }, [error]); 25 | 26 | return { 27 | counts: { 28 | users: data?.usersCount, 29 | clients: data?.clientsCount, 30 | signUps: data?.newSignUps, 31 | }, 32 | lastUsers: data?.getUsers.items || [], 33 | loading, 34 | error, 35 | errorCode, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/src/hooks/useRegister.ts: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { useCallback, useEffect } from 'react'; 3 | import { Action, useMutation } from 'react-fetching-library'; 4 | import { useLocation } from 'react-router'; 5 | 6 | export interface RegisterData { 7 | nickname: string; 8 | firstName: string; 9 | lastName: string; 10 | email: string; 11 | password: string; 12 | passwordConfirm: string; 13 | } 14 | 15 | const registerAction = (query?: Record) => (body: RegisterData): Action => ({ 16 | method: 'POST', 17 | endpoint: `/auth/register?redirect_uri=${encodeURIComponent(query?.redirect_uri || '/')}`, 18 | body, 19 | }) 20 | 21 | export const useRegister = () => { 22 | const { query } = useLocation(); 23 | const { mutate, loading, payload, error } = useMutation(registerAction(query)); 24 | const { handleSubmit, setError, ...form } = useForm(); 25 | 26 | const onSubmit = useCallback((data: RegisterData) => { 27 | mutate(data) 28 | .then(value => { 29 | if (!value.error) { 30 | window.location.href = value.payload.returnTo || '/'; 31 | } 32 | }) 33 | }, [mutate]); 34 | 35 | useEffect(() => { 36 | if (error) { 37 | 38 | } 39 | }, [error]); 40 | 41 | return { 42 | onSubmit: handleSubmit(onSubmit), 43 | ...form, 44 | loading, 45 | error, 46 | payload, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/src/hooks/useTfaDisable.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@apollo/client'; 2 | import { DisableTfaDocument, DisableTfaMutation } from '../generated/graphql'; 3 | import { useCallback } from 'react'; 4 | import { useSnackbar } from 'notistack'; 5 | 6 | export const useTfaDisable = () => { 7 | const { enqueueSnackbar } = useSnackbar(); 8 | const [mutate, { loading }] = useMutation(DisableTfaDocument); 9 | 10 | const disableTfa = useCallback(async () => { 11 | try { 12 | const result = await mutate(); 13 | if (result.data?.disableTfa) { 14 | enqueueSnackbar('Two factor authentication disabled', { 15 | variant: 'info', 16 | }); 17 | } 18 | return result.data?.disableTfa; 19 | } catch (e) { 20 | console.log(e); 21 | enqueueSnackbar(e.message, { 22 | variant: 'error', 23 | }); 24 | } 25 | }, []); 26 | 27 | return { 28 | disableTfa, 29 | loading, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/hooks/useTfaForm.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'react-router'; 2 | import { Action, useMutation } from 'react-fetching-library'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useCallback, useEffect } from 'react'; 5 | 6 | export interface TfaData { 7 | code: string; 8 | remember?: boolean; 9 | } 10 | 11 | const tfaAction = (query?: Record) => (body: TfaData): Action => ({ 12 | method: 'POST', 13 | endpoint: `/auth/tfa?redirect_uri=${encodeURIComponent(query?.redirect_uri || '/')}`, 14 | body, 15 | }); 16 | 17 | export const useTfaForm = (remember = false) => { 18 | const { query } = useLocation(); 19 | const { loading, payload, error, mutate } = useMutation(tfaAction(query)); 20 | const { handleSubmit, setError, ...form } = useForm({ 21 | defaultValues: { 22 | remember, 23 | }, 24 | }); 25 | 26 | const onSubmit = useCallback((data: TfaData) => { 27 | mutate(data) 28 | .then(value => { 29 | if (!value.error) { 30 | window.location.href = value.payload.returnTo || '/'; 31 | } 32 | }); 33 | }, [mutate]); 34 | 35 | useEffect(() => { 36 | if (error) { 37 | const { message = 'invalid OTP code' } = payload; 38 | setError([{ 39 | name: 'code', 40 | type: 'network', 41 | message, 42 | }]) 43 | } 44 | }, [error, payload, setError]); 45 | 46 | return { 47 | onSubmit: handleSubmit(onSubmit), 48 | ...form, 49 | loading, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/src/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GetUserDocument, GetUserQuery, GetUserQueryVariables } from '../generated/graphql'; 3 | 4 | export const useUser = (id: string) => { 5 | const { data, loading, error } = useQuery(GetUserDocument, { 6 | variables: { id }, 7 | skip: !id, 8 | }); 9 | 10 | return { 11 | user: data?.getUser, 12 | loading, 13 | error, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/hooks/useUsers.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { GetUsersDocument, GetUsersQuery, GetUsersQueryVariables } from '../generated/graphql'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | export const useUsers = (limit: number = 10, skip: number = 0) => { 6 | const { data, loading, error } = useQuery(GetUsersDocument, { 7 | variables: { 8 | limit, skip, 9 | }, 10 | }); 11 | const [errorCode, setErrorCode] = useState(''); 12 | 13 | useEffect(() => { 14 | if (error && error.graphQLErrors && error.graphQLErrors.length) { 15 | const firstError = error.graphQLErrors[0]; 16 | if (firstError.extensions && firstError.extensions.code) { 17 | setErrorCode(firstError.extensions.code); 18 | } 19 | } 20 | }, [error]); 21 | 22 | return { 23 | users: (data?.getUsers.items || []), 24 | total: data?.getUsers.paginationInfo.total || 0, 25 | loading, 26 | error, 27 | errorCode, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | html. body. #root { 16 | height: 100%; 17 | } 18 | 19 | *:focus { 20 | outline: none; 21 | } 22 | 23 | .code-input input::-webkit-outer-spin-button, 24 | .code-input input::-webkit-inner-spin-button { 25 | -webkit-appearance: none; 26 | margin: 0; 27 | } 28 | 29 | .code-input input { 30 | -moz-appearance: textfield; 31 | } 32 | 33 | .code-input input:nth-child(3) { 34 | margin-right: 16px !important; 35 | } 36 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ThemeProvider, CssBaseline } from '@material-ui/core'; 4 | import { SnackbarProvider } from 'notistack'; 5 | import { App } from './App'; 6 | import * as serviceWorker from './serviceWorker'; 7 | import { light } from './theme/firebase'; 8 | import './index.css'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | , 19 | document.getElementById('root') 20 | ); 21 | 22 | // If you want your app to work offline and load faster, you can change 23 | // unregister() to register() below. Note this comes with some pitfalls. 24 | // Learn more about service workers: https://bit.ly/CRA-PWA 25 | serviceWorker.unregister(); 26 | -------------------------------------------------------------------------------- /client/src/pages/app/clients.page.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | import { Box, Paper, Typography, Button } from '@material-ui/core'; 4 | import { Add } from '@material-ui/icons'; 5 | import { ClientNewDialog, ClientsList } from '../../components/clients'; 6 | import { userCan } from '../../utils'; 7 | 8 | const ClientsPage: React.FC = () => { 9 | const [open, setOpen] = useState(false); 10 | 11 | return ( 12 |
13 | 14 | All Clients 15 | {userCan('client', 'create:any') && ( 16 | 24 | )} 25 | 26 | 27 | 28 | 29 | setOpen(false)} open={open}/> 30 |
31 | ) 32 | } 33 | 34 | export default ClientsPage; 35 | -------------------------------------------------------------------------------- /client/src/pages/authorize.page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | import { GuestLayout } from '../components/layouts'; 4 | import { Typography } from '@material-ui/core'; 5 | import { AuthorizeForm } from '../components'; 6 | 7 | const AuthorizePage: React.FC = () => { 8 | return ( 9 | 10 | 11 | Authorize 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export default AuthorizePage; 19 | -------------------------------------------------------------------------------- /client/src/pages/home.page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect, RouteComponentProps } from 'react-router'; 3 | import { useCurrentUser } from '../hooks'; 4 | 5 | const HomePage: React.FC = () => { 6 | const user = useCurrentUser(); 7 | 8 | if (!user) { 9 | return 10 | } 11 | return 12 | } 13 | 14 | export default HomePage; 15 | -------------------------------------------------------------------------------- /client/src/pages/login.page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | import { Typography } from '@material-ui/core'; 4 | import { GuestLayout } from '../components/layouts'; 5 | import { LoginForm } from '../components/guest'; 6 | 7 | const LoginPage: React.FC = () => { 8 | return ( 9 | 10 | 11 | Sign in 12 | 13 | 14 | 15 | ) 16 | }; 17 | 18 | export default LoginPage; 19 | -------------------------------------------------------------------------------- /client/src/pages/register.page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import { Typography } from '@material-ui/core'; 4 | import { GuestLayout } from '../components/layouts'; 5 | import { RegisterForm } from '../components/guest'; 6 | 7 | const RegisterPage: React.FC = () => { 8 | return ( 9 | 10 | 11 | Sign up 12 | 13 | 14 | 15 | ) 16 | }; 17 | 18 | export default RegisterPage; 19 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // noinspection ES6UnusedImports 4 | import * as history from 'history'; 5 | declare module 'history' { 6 | export interface Location { 7 | query: Record; 8 | } 9 | } 10 | 11 | declare global { 12 | interface Window { 13 | __APP_DATA__: { 14 | user?: any; 15 | currentSession?: string; 16 | appName?: string; 17 | grants: { 18 | [key: string]: { 19 | [key: string]: { 20 | 'create:any': string[]; 21 | 'update:any': string[]; 22 | 'delete:any': string[]; 23 | 'read:any': string[]; 24 | }; 25 | }; 26 | }; 27 | [key: string]: any; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /client/src/theme/firebase/dark.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/merge'; 2 | import firebaseBase from './base'; 3 | 4 | const dark = { 5 | palette: { 6 | type: 'dark', 7 | primary: { 8 | contrastText: 'rgba(0, 0, 0, 0.87)', 9 | light: 'rgb(166, 212, 250)', 10 | main: '#90caf9', 11 | dark: 'rgb(100, 141, 174)' 12 | }, 13 | secondary: { 14 | contrastText: 'rgba(0, 0, 0, 0.87)', 15 | dark: 'rgb(170, 100, 123)', 16 | light: 'rgb(246, 165, 192)', 17 | main: '#f48fb1' 18 | }, 19 | error: { 20 | contrastText: '#fff', 21 | dark: '#d32f2f', 22 | light: '#e57373', 23 | main: '#f44336' 24 | }, 25 | divider: 'rgba(255, 255, 255, 0.12)', 26 | background: { 27 | default: '#121212', 28 | level1: '#212121', 29 | level2: '#333', 30 | paper: '#424242' 31 | }, 32 | text: { 33 | disabled: 'rgba(255, 255, 255, 0.5)', 34 | hint: 'rgba(255, 255, 255, 0.5)', 35 | icon: 'rgba(255, 255, 255, 0.5)', 36 | primary: '#fff', 37 | secondary: 'rgba(255, 255, 255, 0.7)' 38 | } 39 | }, 40 | overrides: { 41 | MuiAppBar: { 42 | colorPrimary: { 43 | backgroundColor: firebaseBase.palette.background.default, 44 | color: firebaseBase.palette.text.primary 45 | } 46 | } 47 | } 48 | } 49 | 50 | export default merge({}, firebaseBase, dark); 51 | -------------------------------------------------------------------------------- /client/src/theme/firebase/index.ts: -------------------------------------------------------------------------------- 1 | export { default as light } from './light'; 2 | export { default as dark } from './dark'; 3 | -------------------------------------------------------------------------------- /client/src/theme/firebase/light.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/merge'; 2 | import firebaseBase from './base'; 3 | 4 | const light = { 5 | palette: { 6 | type: 'light', 7 | primary: { 8 | light: '#63ccff', 9 | main: '#009be5', 10 | dark: '#006db', 11 | } 12 | } 13 | } 14 | 15 | export default merge({}, firebaseBase, light); 16 | -------------------------------------------------------------------------------- /client/src/utils/async-load.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType, lazy } from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | 4 | export const asyncLoad = (factory: () => Promise<{ default: ComponentType> }>) => { 5 | const Comp = lazy(factory); 6 | return (props: RouteComponentProps) => ; 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/utils/enum-maps.tsx: -------------------------------------------------------------------------------- 1 | import { GrantTypes, ResponseModes, ResponseTypes, TokenAuthMethod } from '../generated/graphql'; 2 | 3 | const labelFromValue = (v: string) => v.toLowerCase().split('_').join(' ').replace(/(?:^|\s)\S/g, (a) => a.toUpperCase()); 4 | 5 | const mapFromEnum = (en: any) => Object.values(en).map((v: any) => ({ 6 | value: v, 7 | label: labelFromValue(v), 8 | })) 9 | 10 | export const grantTypes = mapFromEnum(GrantTypes); 11 | 12 | export const responseModes = mapFromEnum(ResponseModes); 13 | 14 | export const responseTypes = mapFromEnum(ResponseTypes); 15 | 16 | export const authMethods = mapFromEnum(TokenAuthMethod); 17 | 18 | export const openIDScopes = [ 19 | 'openid', 'email', 'profile', 'offline_access', 20 | ]; 21 | -------------------------------------------------------------------------------- /client/src/utils/get-client-logo.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from '@material-ui/core'; 2 | import React from 'react'; 3 | 4 | export const getClientLogo = (client: any) => { 5 | if (client.description?.logo_uri) { 6 | return 7 | } 8 | return ( 9 | 10 | {client.name[0].toUpperCase()} 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './async-load'; 2 | export * from './get-client-logo'; 3 | export * from './remove-falsy'; 4 | export * from './enum-maps'; 5 | export * from './userCan'; 6 | 7 | export const capitalize = (str: string) => str && str.replace(/(?:^|\s)\S/g, (a) => a.toUpperCase()); 8 | 9 | -------------------------------------------------------------------------------- /client/src/utils/remove-falsy.ts: -------------------------------------------------------------------------------- 1 | 2 | export function isEmpty(obj: object) { 3 | return !obj || Object.keys(obj).length === 0; 4 | } 5 | 6 | export function isNil(value: any) { 7 | return !value; 8 | } 9 | 10 | export const removeFalsy = (obj: any): any => { 11 | if (Array.isArray(obj)) { 12 | return obj.filter(removeFalsy); 13 | } 14 | if (typeof obj === 'object' && obj !== null) { 15 | return Object.keys(obj).filter(k => { 16 | if (isNil(obj[k])) { 17 | return false; 18 | } 19 | return !(typeof obj[k] === 'object' && isEmpty(obj[k])); 20 | }).reduce((acc, curr) => ({ 21 | [curr]: removeFalsy(obj[curr]), 22 | ...acc, 23 | }), {}); 24 | } 25 | return obj; 26 | } 27 | -------------------------------------------------------------------------------- /client/src/utils/userCan.ts: -------------------------------------------------------------------------------- 1 | import { dummyGrants, dummyUser } from './dummy'; 2 | 3 | export type Perms = 'create:any' | 'update:any' | 'delete:any' | 'read:any'; 4 | 5 | export const userCan = (resource: string, perm: Perms, user?: { role: string }) => { 6 | if (!user) { 7 | if (process.env.NODE_ENV === 'development') { 8 | user = dummyUser; 9 | } else { 10 | user = window.__APP_DATA__.user; 11 | } 12 | } 13 | if (!user) { 14 | return false; 15 | } 16 | let grant; 17 | if (process.env.NODE_ENV === 'development') { 18 | grant = dummyGrants; 19 | } else { 20 | grant = window.__APP_DATA__.grants; 21 | } 22 | const userGrants = grant[user.role]; 23 | if (!userGrants || !userGrants[resource]) { 24 | return false; 25 | } 26 | const res = userGrants[resource]; 27 | return !!(res[perm]?.length); 28 | } 29 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /client/views/form_post.html: -------------------------------------------------------------------------------- 1 | 2 | Submit 3 | 4 |
5 | {{#each hiddenFields}} 6 | 7 | {{/each}} 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | version: "3" 3 | 4 | services: 5 | db: 6 | image: postgres:11-alpine 7 | environment: 8 | - POSTGRES_DB=argo 9 | - POSTGRES_USER=argo 10 | - POSTGRES_PASSWORD=secret 11 | volumes: 12 | - ./db-data:/var/lib/postgresql/data 13 | ports: 14 | - 5432:5432 15 | 16 | adminer: 17 | image: adminer 18 | ports: 19 | - 8081:8080 20 | 21 | redis: 22 | image: redis:alpine 23 | ports: 24 | - 6381:6379 25 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | ``` 30 | $ GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/docs/api/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: introduction 3 | title: Introduction 4 | --- 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/docs/api/oauth2/client_credentials.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: client_credentials 3 | title: Client credentials grant 4 | --- 5 | 6 | The Client credentials grant is mostly used for server to server connections and is not bound to a user. it is used to retrieve some information about the Client itself (name, logo, descriptions ecc.) 7 | 8 | **Endpoint**: `/oauth2/token` 9 | 10 | **Method**: `POST` 11 | 12 | ## Request payload 13 | 14 | ```json 15 | { 16 | "client_id": "c02d5bf5-993e-4c6a-a248-6c307cc7681b", 17 | "client_secret": "c5bb2489292fac7711baedd65d87296261d08cdbdde2073c9fdb29941ac5446a", 18 | "grant_type": "client_credentials", 19 | "scope": "read:something list:something" 20 | } 21 | ``` 22 | 23 | ### Payload description 24 | 25 | | Name | Required | Default | Description | 26 | |---------------|----------|---------|--------------------------------------------------| 27 | | grant_type | true | null | Must be set as `client_credentials` | 28 | | client_id | true | null | Client id that is requesting access | 29 | | client_secret | true | null | Client secret that is requesting access | 30 | | scope | false | null | Optional space separated list of scopes to apply | 31 | 32 | ## Response payload 33 | ```json 34 | { 35 | "access_token": "JWT TOKEN" 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/docs/api/oauth2/introspection.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: introspection 3 | title: Introspection 4 | --- 5 | 6 | The OAuth2 server expose an endpoint to get information about an access_token 7 | 8 | **Endpoint**: `/oauth2/introspect` 9 | 10 | ***Method***: `GET` 11 | 12 | ## Request Headers 13 | ```json 14 | { 15 | "Authorization": "Bearer ACCESS_TOKEN" 16 | } 17 | ``` 18 | 19 | ## Response payload 20 | ```json 21 | { 22 | "active": true, 23 | "exp": 1589795077, 24 | "client_id": "c02d5bf5-993e-4c6a-a248-6c307cc7681b", 25 | "scope": "openid email profile page:read", 26 | "username": "test@mail.com", 27 | "sub": "deb3422d-f5cb-4c50-bb2d-ba92477a1201" 28 | } 29 | ``` 30 | 31 | ### Payload description 32 | 33 | | Name | Always | type | Description | 34 | |-----------|--------|---------|---------------------------------------------------------------------| 35 | | active | true | boolean | Check if the access_token is valid (not expired, exists, not revoked) | 36 | | exp | true | number | Return the token expiration date | 37 | | client_id | true | string | The Client id associated with the access_token | 38 | | scope | true | string | space separated scopes associated with this access_token | 39 | | username | false | string | if found, return a human readable identifier of the associated user | 40 | | sub | false | string | associated user id | 41 | -------------------------------------------------------------------------------- /docs/docs/api/oauth2/password.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: password 3 | title: Password grant 4 | --- 5 | 6 | The Password grant is mostly used for first party clients, for example the organization native app would like to show the user a Login View inside the App without the necessity to open a webview to start the Authorization flow 7 | 8 | **Endpoint**: `/oauth2/token` 9 | 10 | **Method**: `POST` 11 | 12 | ## Request payload 13 | 14 | ```json 15 | { 16 | "username": "me@mail.com", 17 | "password": "password", 18 | "client_id": "c02d5bf5-993e-4c6a-a248-6c307cc7681b", 19 | "grant_type": "password", 20 | "scope": "read:something list:something" 21 | } 22 | ``` 23 | 24 | ### Payload description 25 | 26 | | Name | Required | Default | Description | 27 | |---------------|----------|---------|--------------------------------------------------| 28 | | username | true | null | user username | 29 | | password | true | null | user password | 30 | | grant_type | true | null | Must be set as `password` | 31 | | client_id | true | null | Client id that is requesting access | 32 | | client_secret | false | null | Optional client secret that is requesting access | 33 | | scope | false | null | Optional space separated list of scopes to apply | 34 | 35 | 36 | ## Response payload 37 | ```json 38 | { 39 | "access_token": "JWT TOKEN" 40 | } 41 | ``` 42 | 43 | -------------------------------------------------------------------------------- /docs/docs/api/openid/openid-configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: openid-configuration 3 | title: OpenID configuration 4 | --- 5 | 6 | The OAuth2 server expose an endpoint to get the OpenID Connect config 7 | 8 | **Endpoint**: `/.well-known/openid-configuration` 9 | 10 | **Method**: `GET` 11 | 12 | ## Response payload 13 | 14 | ```json 15 | { 16 | "response_types_supported": [ 17 | "code" 18 | ], 19 | "grant_types_supported": [ 20 | "password", 21 | "authorization_code", 22 | "refresh_token", 23 | "client_credentials" 24 | ], 25 | "response_modes_supported": [ 26 | "query", 27 | "fragment", 28 | "form_post" 29 | ], 30 | "scopes_supported": [ 31 | "openid", 32 | "email", 33 | "profile", 34 | "offline_access" 35 | ], 36 | "token_endpoint_auth_methods_supported": [ 37 | "client_secret_post", 38 | "client_secret_basic", 39 | "none" 40 | ], 41 | "subject_types_supported": [ 42 | "public" 43 | ], 44 | "issuer": "http://localhost:4000/", 45 | "authorization_endpoint": "http://localhost:4000/oauth2/authorize", 46 | "token_endpoint": "http://localhost:4000/oauth2/token", 47 | "introspection_endpoint": "http://localhost:4000/oauth2/introspect", 48 | "revocation_endpoint": "http://localhost:4000/oauth2/revoke", 49 | "registration_endpoint": "http://localhost:4000/clients", 50 | "jwks_uri": "http://localhost:4000/.well-known/jwks.json", 51 | "userinfo_endpoint": "http://localhost:4000/userinfo" 52 | } 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/docs/api/openid/userinfo.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: userinfo 3 | title: User info 4 | --- 5 | 6 | The OAuth2 server expose an endpoint to get information about the user associated with the access token. 7 | The return payload depends on the `openid` scopes applied. 8 | 9 | **Endpoint**: `/userinfo` 10 | 11 | ***Method***: `GET` 12 | 13 | ## Request Headers 14 | ```json 15 | { 16 | "Authorization": "Bearer ACCESS_TOKEN" 17 | } 18 | ``` 19 | 20 | ## Response payload 21 | ```json 22 | { 23 | "sub": "deb3422d-f5cb-4c50-bb2d-ba92477a1201", 24 | "email": "mail@demo.com", 25 | "name": "Demo", 26 | "picture": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50", 27 | "updated_at": "2020-01-22 10:10:10" 28 | } 29 | ``` 30 | 31 | ### Payload description 32 | 33 | | Name | Required scope | Description | 34 | |------------|----------------|--------------------------| 35 | | sub | openid | User unique identifier | 36 | | email | email | User email | 37 | | name | profile | Username | 38 | | picture | profile | User profile picture url | 39 | | updated_at | profile | User last update date | 40 | 41 | -------------------------------------------------------------------------------- /docs/docs/example/client_credentials.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: client_credentials 3 | title: Client credentials example 4 | --- 5 | 6 | ## Intro 7 | 8 | This example demonstrates how to consume the OAuth2 server using the client_credentials grant with a server side application (powered by `express`), assuming the OAuth2 server is responding at `http://oauth.server.com` and the client server is responding at `http://client.server.com` 9 | 10 | ### Client data 11 | 12 | ```js 13 | const client = { 14 | id: 'c02d5bf5-993e-4c6a-a248-6c307cc7681b', 15 | secret: 'c5bb2489292fac7711baedd65d87296261d08cdbdde2073c9fdb29941ac5446a', 16 | grantTypes: ['client_credentials'], 17 | authMethods: ['client_secret_post'], 18 | //... 19 | } 20 | ``` 21 | 22 | ## Server implementation 23 | 24 | ### Get the Client token 25 | 26 | ```js 27 | const fetch = require('node-fetch'); 28 | 29 | // Request an access_token 30 | fetch('http://auth.server.com/oauth2/token', { 31 | method: 'POST', 32 | headers: { 'Content-type': 'application/json', accept: 'application/json' }, 33 | body: JSON.stringify({ 34 | code, 35 | client_id: client.id, 36 | client_secret: client.secret, 37 | grant_type: 'client_credentials', 38 | }), 39 | }).then(r => r.json()) // TODO handle http errors 40 | .then(payload => { 41 | // Client is authenticated, payload contains the access_token 42 | }); 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getting-started 3 | title: Getting started 4 | --- 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "docusaurus start", 7 | "build": "docusaurus build", 8 | "swizzle": "docusaurus swizzle", 9 | "deploy": "docusaurus deploy" 10 | }, 11 | "dependencies": { 12 | "@docusaurus/core": "^2.0.0-alpha.56", 13 | "@docusaurus/preset-classic": "^2.0.0-alpha.56", 14 | "classnames": "^2.2.6", 15 | "react": "^16.8.4", 16 | "react-dom": "^16.8.4" 17 | }, 18 | "browserslist": { 19 | "production": [ 20 | ">0.2%", 21 | "not dead", 22 | "not op_mini all" 23 | ], 24 | "development": [ 25 | "last 1 chrome version", 26 | "last 1 firefox version", 27 | "last 1 safari version" 28 | ] 29 | } 30 | } -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | docs: [ 3 | { 4 | type: 'doc', 5 | id: 'getting-started', 6 | }, 7 | { 8 | type: 'doc', 9 | id: 'client-specification', 10 | }, 11 | { 12 | type: 'category', 13 | label: 'Examples', 14 | items: [ 15 | 'example/auth_code', 16 | 'example/auth_code_pkce', 17 | 'example/client_credentials', 18 | 'example/password', 19 | 'example/refresh_token' 20 | ] 21 | } 22 | ], 23 | api: [ 24 | { 25 | type: 'doc', 26 | id: 'api/introduction', 27 | }, 28 | { 29 | type: 'doc', 30 | id: 'api/scopes', 31 | }, 32 | { 33 | type: 'category', 34 | label: 'OAuth2', 35 | items: [ 36 | 'api/oauth2/authorization_code', 37 | 'api/oauth2/client_credentials', 38 | 'api/oauth2/password', 39 | 'api/oauth2/refresh_token', 40 | 'api/oauth2/introspection', 41 | ], 42 | }, 43 | { 44 | type: 'category', 45 | label: 'OpenID', 46 | items: [ 47 | 'api/openid/openid-configuration', 48 | 'api/openid/userinfo', 49 | 'api/openid/jwks', 50 | ], 51 | }, 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #25c2a0; 11 | --ifm-color-primary-dark: rgb(33, 175, 144); 12 | --ifm-color-primary-darker: rgb(31, 165, 136); 13 | --ifm-color-primary-darkest: rgb(26, 136, 112); 14 | --ifm-color-primary-light: rgb(70, 203, 174); 15 | --ifm-color-primary-lighter: rgb(102, 212, 189); 16 | --ifm-color-primary-lightest: rgb(146, 224, 208); 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgb(72, 77, 91); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | -------------------------------------------------------------------------------- /docs/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | @media screen and (max-width: 966px) { 16 | .heroBanner { 17 | padding: 2rem; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | 27 | .features { 28 | display: flex; 29 | align-items: center; 30 | padding: 2rem 0; 31 | width: 100%; 32 | } 33 | 34 | .featureImage { 35 | height: 200px; 36 | width: 200px; 37 | } 38 | -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /ormconfig.js: -------------------------------------------------------------------------------- 1 | const isTest = process.env.NODE_ENV === 'test'; 2 | 3 | module.exports = { 4 | type: 'postgres', 5 | database: process.env.DB_NAME, 6 | host: process.env.DB_HOST, 7 | port: parseInt(process.env.DB_PORT, 10), 8 | username: process.env.DB_USERNAME, 9 | password: process.env.DB_PASSWORD, 10 | logging: process.env.NODE_ENV === 'debug', 11 | cli: { 12 | migrationsDir: 'src/apps/init/db/migrations', 13 | entitiesDir: 'src/apps/oauth2/entities', 14 | }, 15 | migrations: isTest ? [ 16 | 'src/apps/init/db/migrations/**/*.ts' 17 | ] : [ 18 | 'dist/apps/init/db/migrations/**/*.js' 19 | ], 20 | entities: isTest ? [ 21 | 'src/apps/oauth2/entities/**/*.ts', 22 | ] : [ 23 | 'dist/apps/oauth2/entities/**/*.js' 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /src/apps/cli/main.ts: -------------------------------------------------------------------------------- 1 | import { BootstrapConsole } from 'nestjs-console'; 2 | import { Command } from 'commander'; 3 | import { CliModule } from './modules/cli/cli.module'; 4 | 5 | export default async function bootstrap(command: Command) { 6 | const boot = new BootstrapConsole({ 7 | module: CliModule, 8 | useDecorators: true, 9 | contextOptions: { 10 | logger: ['error'], 11 | }, 12 | }); 13 | boot.init().then(async app => { 14 | try { 15 | await app.init(); 16 | await boot.boot(['', '', ...command.args]); 17 | process.exit(0); 18 | } catch (e) { 19 | // tslint:disable-next-line:no-console 20 | console.error(e); 21 | process.exit(1); 22 | } 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/apps/cli/modules/cli/cli.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import * as configs from '@config'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import * as entities from '@app/entities'; 6 | import { ConsoleModule } from 'nestjs-console'; 7 | import { KeysService } from './services'; 8 | import { CipherModule } from '@app/lib/cipher'; 9 | 10 | @Module({ 11 | imports: [ 12 | ConfigModule.forRoot({ 13 | load: Object.values(configs), 14 | }), 15 | TypeOrmModule.forRootAsync({ 16 | imports: [ConfigModule], 17 | inject: [ConfigService], 18 | useFactory: (config: ConfigService) => { 19 | return { 20 | ...config.get('db'), 21 | entities: Object.values(entities), 22 | }; 23 | }, 24 | }), 25 | CipherModule.registerAsync({ 26 | imports: [ConfigModule], 27 | inject: [ConfigService], 28 | useFactory: (config: ConfigService) => config.get('crypto'), 29 | }), 30 | TypeOrmModule.forFeature(Object.values(entities)), 31 | ConsoleModule, 32 | ], 33 | providers: [ 34 | KeysService, 35 | ], 36 | }) 37 | export class CliModule {} 38 | -------------------------------------------------------------------------------- /src/apps/cli/modules/cli/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/src/apps/cli/modules/cli/index.ts -------------------------------------------------------------------------------- /src/apps/cli/modules/cli/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './keys.service'; 2 | -------------------------------------------------------------------------------- /src/apps/init/db/seed-runner.ts: -------------------------------------------------------------------------------- 1 | import { Connection, EntityManager } from 'typeorm'; 2 | import { Seeder } from './seeder.interface'; 3 | import { Type } from '@nestjs/common'; 4 | 5 | export class SeedRunner { 6 | constructor( 7 | private readonly connection: Connection, 8 | ) {} 9 | 10 | async runAll(seeders: Type[]) { 11 | await this.connection.transaction(async em => { 12 | await Promise.all( 13 | seeders.map(async seeder => await this.run(seeder, em)), 14 | ); 15 | }); 16 | } 17 | 18 | async revertAll(seeders: Type[]) { 19 | await this.connection.transaction(async em => { 20 | await Promise.all( 21 | seeders.map(async seeder => await this.revert(seeder, em)), 22 | ); 23 | }); 24 | } 25 | 26 | async run(seeder: Type, manager?: EntityManager) { 27 | const instance = new seeder(); 28 | const hasCheck = 'shouldSeed' in instance; 29 | if (!hasCheck || await instance.shouldSeed(manager)) { 30 | await instance.run(manager); 31 | } else { 32 | await Promise.resolve(); 33 | } 34 | } 35 | 36 | async revert(seeder: Type, manager?: EntityManager) { 37 | const instance = new seeder(); 38 | await instance.revert(manager); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/apps/init/db/seeder.interface.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager } from 'typeorm'; 2 | 3 | export interface Seeder { 4 | run(em: EntityManager): Promise; 5 | revert(em: EntityManager): Promise; 6 | shouldSeed?(em: EntityManager): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/apps/init/db/seeds/admin-user.seeder.ts: -------------------------------------------------------------------------------- 1 | import { Seeder } from '@init/db/seeder.interface'; 2 | import { EntityManager } from 'typeorm'; 3 | import { User } from '@app/entities'; 4 | import { Roles } from '@app/modules/auth'; 5 | 6 | 7 | export class AdminUserSeeder implements Seeder { 8 | async run(em: EntityManager): Promise { 9 | await em.getRepository(User).save( 10 | em.getRepository(User).create({ 11 | nickname: 'Admin', 12 | email: 'admin@admin.com', 13 | password: 'admin', 14 | role: Roles.ADMIN, 15 | }), 16 | ); 17 | } 18 | 19 | async revert(em: EntityManager): Promise { 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/apps/init/db/seeds/index.ts: -------------------------------------------------------------------------------- 1 | export * from './admin-user.seeder'; 2 | -------------------------------------------------------------------------------- /src/apps/init/init.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import * as configs from '@config'; 5 | 6 | @Module({ 7 | imports: [ 8 | ConfigModule.forRoot({ 9 | isGlobal: true, 10 | load: Object.values(configs), 11 | }), 12 | TypeOrmModule.forRootAsync({ 13 | imports: [ConfigModule], 14 | inject: [ConfigService], 15 | useFactory: (config: ConfigService) => ({ 16 | ...config.get('db'), 17 | }), 18 | }), 19 | ], 20 | }) 21 | export class InitModule {} 22 | -------------------------------------------------------------------------------- /src/apps/init/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { getConnectionToken } from '@nestjs/typeorm'; 3 | import { Connection } from 'typeorm'; 4 | import * as seeds from './db/seeds'; 5 | import { SeedRunner } from './db/seed-runner'; 6 | import { InitModule } from './init.module'; 7 | 8 | export default async function bootstrap() { 9 | process.env.NODE_LOG = 'debug'; 10 | 11 | const app = await NestFactory.createApplicationContext(InitModule); 12 | 13 | const connection = app.get(getConnectionToken()); 14 | 15 | await connection.runMigrations({ transaction: 'all' }); 16 | 17 | const seeder = new SeedRunner(connection); 18 | 19 | await seeder.runAll(Object.values(seeds)); 20 | 21 | await connection.close(); 22 | 23 | process.exit(0); 24 | } 25 | -------------------------------------------------------------------------------- /src/apps/oauth2/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Render, Req, UseFilters, UseGuards } from '@nestjs/common'; 2 | import { AuthenticatedGuard, CurrentUser } from './modules/auth'; 3 | import { User } from './entities'; 4 | import { Request } from 'express'; 5 | import { classToPlain } from 'class-transformer'; 6 | import { InjectRolesBuilder, RolesBuilder } from 'nest-access-control'; 7 | import { ForbiddenExceptionFilter } from '@app/modules/auth/filters'; 8 | import { ConfigService } from '@nestjs/config'; 9 | 10 | @Controller() 11 | export class AppController { 12 | constructor( 13 | @InjectRolesBuilder() 14 | private rb: RolesBuilder, 15 | private readonly config: ConfigService, 16 | ) {} 17 | 18 | @Get('/') 19 | @Render('index') 20 | homepage( 21 | @Req() req: Request, 22 | @CurrentUser() user?: User, 23 | ) { 24 | return { 25 | user: classToPlain(user), 26 | grants: this.rb.getGrants(), 27 | csrfToken: req.csrfToken(), 28 | currentSession: req.session?.id, 29 | facebookLoginUrl: this.config.get('social.facebook.loginUrl')(encodeURIComponent('/')), 30 | googleLoginUrl: this.config.get('social.google.loginUrl')(encodeURIComponent('/')), 31 | appName: this.config.get('app.appName'), 32 | } 33 | } 34 | 35 | @UseFilters(ForbiddenExceptionFilter) 36 | @UseGuards(AuthenticatedGuard) 37 | @Get('/app*') 38 | @Render('index') 39 | getApp( 40 | @Req() req: Request, 41 | @CurrentUser() user?: User, 42 | ) { 43 | return this.homepage(req, user); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; 2 | import { Field, ObjectType } from '@nestjs/graphql'; 3 | 4 | @ObjectType({ isAbstract: true }) 5 | export abstract class BaseEntity { 6 | @Field() 7 | @PrimaryGeneratedColumn('uuid') 8 | id: string; 9 | 10 | @Field() 11 | @CreateDateColumn() 12 | createdAt: Date; 13 | 14 | @Field() 15 | @UpdateDateColumn() 16 | updatedAt: Date; 17 | } 18 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/base.token.ts: -------------------------------------------------------------------------------- 1 | import { Column } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | 4 | export abstract class BaseToken extends BaseEntity { 5 | @Column({ type: 'timestamp without time zone' }) 6 | expiresAt: Date; 7 | 8 | @Column({ type: 'boolean', default: false }) 9 | revoked: boolean; 10 | 11 | @Column({ type: 'timestamp without time zone', nullable: true }) 12 | revokedAt: Date; 13 | 14 | public abstract toPayload(): any; 15 | } 16 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user'; 2 | export * from './o-auth-access-token'; 3 | export * from './o-auth-client'; 4 | export * from './o-auth-code'; 5 | export * from './o-auth-refresh-token'; 6 | export * from './key'; 7 | export * from './social-login.entity'; 8 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/key.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { BaseEntity } from '@app/entities/base.entity'; 3 | 4 | @Entity() 5 | export class Key extends BaseEntity { 6 | @Column({ type: 'varchar' }) 7 | name: string; 8 | 9 | @Column({ type: 'varchar' }) 10 | type: 'public' | 'private'; 11 | 12 | @Column({ type: 'text' }) 13 | data: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/o-auth-access-token.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | import { OAuthClient } from './o-auth-client'; 4 | import { User } from './user'; 5 | import { toEpochSeconds } from '../utils'; 6 | import { AccessTokenJwtPayload } from '@app/modules/oauth2/interfaces'; 7 | import { GrantTypes } from '@app/modules/oauth2/constants'; 8 | import { BaseToken } from '@app/entities/base.token'; 9 | 10 | @Entity() 11 | export class OAuthAccessToken extends BaseToken { 12 | @Column({ type: 'uuid', nullable: true }) 13 | userId: string; 14 | 15 | @Column({ type: 'uuid', nullable: false }) 16 | clientId: string; 17 | 18 | @Column({ type: 'varchar', array: true, nullable: true }) 19 | scopes: string[]; 20 | 21 | @Column({ type: 'varchar', enum: GrantTypes }) 22 | grantType: GrantTypes; 23 | 24 | @ManyToOne(type => OAuthClient, client => client.tokens, { 25 | onDelete: 'CASCADE', 26 | }) 27 | client!: Promise; 28 | 29 | @ManyToOne(type => User, user => user.tokens, { 30 | onDelete: 'CASCADE', 31 | }) 32 | user: Promise; 33 | 34 | public toPayload(): AccessTokenJwtPayload { 35 | return { 36 | aud: this.clientId, 37 | jti: this.id, 38 | exp: toEpochSeconds(this.expiresAt), 39 | sub: this.userId ? `${this.userId}@users` : `${this.clientId}@clients`, 40 | scopes: this.scopes, 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/o-auth-code.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index, ManyToOne } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | import { OAuthClient } from './o-auth-client'; 4 | import { User } from './user'; 5 | import { AuthCodeData } from '@app/modules/oauth2/interfaces'; 6 | import { toEpochSeconds } from '../utils'; 7 | 8 | @Entity() 9 | export class OAuthCode extends BaseEntity { 10 | @Index() 11 | @Column({ type: 'uuid' }) 12 | userId!: string; 13 | 14 | @Index() 15 | @Column({ type: 'uuid' }) 16 | clientId!: string; 17 | 18 | @Column({ type: 'varchar', array: true, nullable: true }) 19 | scopes!: string[]; 20 | 21 | @Column({ type: 'boolean', default: false }) 22 | revoked: boolean; 23 | 24 | @Column({ type: 'timestamp without time zone' }) 25 | expiresAt: Date; 26 | 27 | @Column({ type: 'varchar' }) 28 | redirectUri: string; 29 | 30 | @ManyToOne(type => OAuthClient, client => client.authCodes, { 31 | onDelete: 'CASCADE', 32 | }) 33 | client: Promise; 34 | 35 | @ManyToOne(type => User, { 36 | eager: true, 37 | onDelete: 'CASCADE', 38 | }) 39 | user: User; 40 | 41 | toPayload(challenge?: string, challengeMethod?: string): AuthCodeData { 42 | return { 43 | id: this.id, 44 | expiresAt: toEpochSeconds(this.expiresAt), 45 | codeChallenge: challenge, 46 | codeChallengeMethod: challengeMethod, 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/o-auth-refresh-token.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | import { RefreshTokenData } from '@app/modules/oauth2/interfaces'; 4 | import { toEpochSeconds } from '../utils'; 5 | import { OAuthAccessToken } from './o-auth-access-token'; 6 | import { BaseToken } from '@app/entities/base.token'; 7 | 8 | @Entity() 9 | export class OAuthRefreshToken extends BaseToken { 10 | @Column({ type: 'uuid' }) 11 | accessTokenId: string; 12 | 13 | @ManyToOne(type => OAuthAccessToken, { 14 | eager: true, 15 | onDelete: 'CASCADE', 16 | }) 17 | accessToken: OAuthAccessToken; 18 | 19 | public toPayload(): RefreshTokenData { 20 | return { 21 | id: this.id, 22 | accessTokenId: this.accessTokenId, 23 | expiresAt: toEpochSeconds(this.expiresAt), 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/oauth2/entities/social-login.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | import { User } from './user'; 4 | 5 | @Entity() 6 | export class SocialLogin extends BaseEntity { 7 | @Column({ type: 'varchar' }) 8 | type: string; 9 | 10 | @Column({ type: 'varchar' }) 11 | socialId: string; 12 | 13 | @Column({ type: 'uuid' }) 14 | userId: string; 15 | 16 | @Column({ type: 'varchar', nullable: true }) 17 | picture: string; 18 | 19 | @ManyToOne(type => User, u => u.socialLogins, { 20 | onDelete: 'CASCADE', 21 | }) 22 | user: Promise; 23 | } 24 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/constants.ts: -------------------------------------------------------------------------------- 1 | export const CIPHER_OPTIONS = 'CIPHER_OPTIONS'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/generators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rs256.generator'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/generators/rs256.generator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { promises } from 'fs'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import forge from 'node-forge'; 5 | import { resolve } from 'path'; 6 | 7 | @Injectable() 8 | export class RS256Generator { 9 | constructor( 10 | private readonly config: ConfigService, 11 | ) {} 12 | 13 | private keyLength = 4096; 14 | 15 | setKeyLength(kl: number) { 16 | this.keyLength = kl; 17 | return this; 18 | } 19 | 20 | /** 21 | * Generate an RSA key pair 22 | */ 23 | generateKeyPair(): Promise { 24 | return new Promise((resolve, reject) => { 25 | forge.pki.rsa.generateKeyPair(this.keyLength, 0x10001, (err, keyPair) => { 26 | if (err) { 27 | return reject(err); 28 | } 29 | resolve(keyPair); 30 | }) 31 | }); 32 | } 33 | 34 | /** 35 | * Save an RSA key pair to file 36 | * @param keyPair 37 | * @param out 38 | * @param pubOut 39 | * @param privOut 40 | */ 41 | async persist( 42 | keyPair: forge.pki.KeyPair, 43 | out = this.config.get('cert.baseCertPath'), 44 | pubOut = 'public.key', 45 | privOut = 'private.key') { 46 | await promises.writeFile( 47 | resolve(out, pubOut), 48 | forge.pki.publicKeyToPem(keyPair.publicKey), 49 | ); 50 | await promises.writeFile( 51 | resolve(out, privOut), 52 | forge.pki.privateKeyToPem(keyPair.privateKey), 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/hash.ts: -------------------------------------------------------------------------------- 1 | import { Options, hash, verify, argon2id } from 'argon2'; 2 | 3 | const defaultConfig: Options = { 4 | type: argon2id, 5 | hashLength: 32, 6 | timeCost: 3, 7 | memoryCost: 4096, 8 | parallelism: 1, 9 | saltLength: 16, 10 | } 11 | 12 | /** 13 | * Argon2 hash wrapper 14 | * Cannot be inside the CipherService to be used outside the NestJs scope (ex. TypeOrm Entities) 15 | * @param value 16 | * @param options 17 | */ 18 | export function hashValue(value: string, options?: Options) { 19 | return hash(value, { 20 | ...defaultConfig, 21 | ...options, 22 | raw: false, 23 | }); 24 | } 25 | 26 | /** 27 | * Argon2 verify wrapper 28 | * Cannot be inside the CipherService to be used outside the NestJs scope (ex. TypeOrm Entities) 29 | * @param value 30 | * @param hashed 31 | * @param options 32 | */ 33 | export function verifyValue(value: string, hashed: string, options?: Options) { 34 | return verify(hashed, value, { 35 | ...defaultConfig, 36 | ...options, 37 | raw: false, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interfaces'; 2 | export * from './constants'; 3 | export * from './services'; 4 | export * from './cipher.module'; 5 | export * from './hash'; 6 | export * from './generators'; 7 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/interfaces/cipher-module.options.ts: -------------------------------------------------------------------------------- 1 | import { ModuleMetadata, Type } from '@nestjs/common/interfaces'; 2 | import { Options } from 'argon2'; 3 | 4 | export interface CipherModuleOptions { 5 | iv: string; 6 | secret: string; 7 | argon2Options?: Omit; 8 | } 9 | 10 | export interface CipherModuleOptionsFactory { 11 | createCipherOptions(): Promise | CipherModuleOptions; 12 | } 13 | 14 | export interface CipherModuleAsyncOptions extends Pick { 15 | useExisting?: Type; 16 | useClass?: Type; 17 | useFactory?: (...args: any[]) => Promise | CipherModuleOptions; 18 | inject?: any[]; 19 | } 20 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cipher-module.options'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/cipher/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cipher.service'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/jwt/index.ts: -------------------------------------------------------------------------------- 1 | export * from './services'; 2 | export * from './jwt.module'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/jwt/jwt.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { CipherModule } from '@app/lib/cipher'; 4 | import { JwtService } from '@app/lib/jwt/services'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { Key } from '@app/entities'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule, 11 | CipherModule.registerAsync({ 12 | imports: [ConfigModule], 13 | inject: [ConfigService], 14 | useFactory: (config: ConfigService) => config.get('crypto'), 15 | }), 16 | TypeOrmModule.forFeature([Key]), 17 | ], 18 | providers: [ 19 | JwtService, 20 | ], 21 | exports: [ 22 | JwtService, 23 | ], 24 | }) 25 | export class JwtModule {} 26 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/jwt/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt.service'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/redis/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const DEFAULT_REDIS_CONNECTION = 'redisDefaultConnection'; 3 | export const REDIS_CONNECTION_NAME = 'REDIS_CONNECTION_NAME'; 4 | export const REDIS_MODULE_OPTIONS = 'REDIS_MODULE_OPTIONS'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/redis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './redis.module'; 2 | export * from './interfaces'; 3 | export * from './constants'; 4 | export * from './utils'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/redis/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { RedisOptions } from 'ioredis'; 2 | import { ModuleMetadata, Type } from '@nestjs/common/interfaces'; 3 | 4 | export interface RedisModuleOptions { 5 | ioredis?: RedisOptions; 6 | url?: string; 7 | connectionName?: string; 8 | retryAttempts?: number; 9 | retryDelay?: number; 10 | } 11 | 12 | export interface RedisOptionsFactory { 13 | createRedisOptions(connectionName?: string): Promise | RedisModuleOptions; 14 | } 15 | 16 | export interface RedisModuleAsyncOptions extends Pick { 17 | connectionName?: string; 18 | useExisting?: Type; 19 | useClass?: Type; 20 | useFactory?: (...args: any[]) => Promise | RedisModuleOptions; 21 | inject?: any[]; 22 | } 23 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | import { RedisModuleAsyncOptions, RedisModuleOptions } from './interfaces'; 3 | import { RedisCoreModule } from './redis-core.module'; 4 | 5 | @Module({}) 6 | export class RedisModule { 7 | static forRoot(options: RedisModuleOptions): DynamicModule { 8 | return { 9 | module: RedisModule, 10 | imports: [RedisCoreModule.forRoot(options)], 11 | }; 12 | } 13 | 14 | static forRootAsync(options: RedisModuleAsyncOptions): DynamicModule { 15 | return { 16 | module: RedisModule, 17 | imports: [RedisCoreModule.forRootAsync(options)], 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/redis/utils.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { delay, retryWhen, scan } from 'rxjs/operators'; 3 | import { Logger } from '@nestjs/common'; 4 | import { DEFAULT_REDIS_CONNECTION } from './constants'; 5 | 6 | const logger = new Logger('RedisModule'); 7 | 8 | export function getConnectionToken(name?: string): string { 9 | return name && name !== DEFAULT_REDIS_CONNECTION 10 | ? `${name}Connection` 11 | : DEFAULT_REDIS_CONNECTION; 12 | } 13 | 14 | export function handleRetry( 15 | retryAttempts = 9, 16 | retryDelay = 3000, 17 | ): (source: Observable) => Observable { 18 | return (source: Observable) => source.pipe( 19 | retryWhen(e => e.pipe( 20 | scan((errorCount, error) => { 21 | logger.error( 22 | `Unable to connect to the Redis database. Retrying (${errorCount + 1})...`, 23 | error.stack, 24 | 'RedisModule', 25 | ); 26 | if (errorCount + 1 >= retryAttempts) { 27 | throw error; 28 | } 29 | return errorCount + 1; 30 | }, 0), 31 | delay(retryDelay), 32 | )), 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/sign/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const SIGN_OPTIONS = 'SIGN_OPTIONS'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/sign/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './signed.guard'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/sign/guards/signed.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | ForbiddenException, 5 | GoneException, 6 | Injectable, 7 | } from '@nestjs/common'; 8 | import { UrlSignService } from '../services'; 9 | import { Request } from 'express'; 10 | import { VerifyResult } from 'signed'; 11 | 12 | @Injectable() 13 | export class SignedGuard implements CanActivate { 14 | constructor( 15 | private readonly urlSignService: UrlSignService, 16 | ) {} 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | const req = context.switchToHttp().getRequest(); 20 | 21 | switch (this.urlSignService.verifyReq(req)) { 22 | case VerifyResult.blackholed: 23 | throw new ForbiddenException(); 24 | case VerifyResult.expired: 25 | throw new GoneException(); 26 | case VerifyResult.ok: 27 | return true; 28 | } 29 | 30 | console.log( 31 | 32 | ) 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/sign/index.ts: -------------------------------------------------------------------------------- 1 | export * from './services'; 2 | export * from './interfaces'; 3 | export * from './sign.module'; 4 | export * from './guards'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/sign/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { SignatureOptions } from 'signed'; 2 | import { ModuleMetadata, Type } from '@nestjs/common/interfaces'; 3 | 4 | export type SignModuleOptions = SignatureOptions; 5 | 6 | export interface SignModuleOptionsFactory { 7 | createSignOptions(): SignModuleOptions | Promise; 8 | } 9 | 10 | export interface SignModuleAsyncOptions extends Pick { 11 | useExisting?: Type; 12 | useClass?: Type; 13 | useFactory?: (...args: any[]) => Promise | SignModuleOptions; 14 | inject?: any[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/sign/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './url-sign.service'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/lib/sign/services/url-sign.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import signed, { Signature, VerifyResult } from 'signed'; 3 | import { SIGN_OPTIONS } from '../constants'; 4 | import { SignModuleOptions } from '../interfaces'; 5 | import { Request } from 'express'; 6 | 7 | @Injectable() 8 | export class UrlSignService { 9 | private signed: Signature; 10 | 11 | constructor( 12 | @Inject(SIGN_OPTIONS) 13 | private readonly options: SignModuleOptions, 14 | ) { 15 | this.signed = signed(options); 16 | } 17 | 18 | sign(url: string) { 19 | return this.signed.sign(url); 20 | } 21 | 22 | verifyReq(req: Request): VerifyResult { 23 | return this.signed.verifyUrl(req, req => req.connection.remoteAddress); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register.controller'; 2 | export * from './login.controller'; 3 | export * from './logout.controller'; 4 | export * from './social.controller'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/controllers/logout.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, Query, Req, Res } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | 4 | @Controller('auth') 5 | export class LogoutController { 6 | @Post('logout') 7 | handleLogout( 8 | @Req() req: Request, 9 | @Res() res: Response, 10 | @Body('redirect_uri') redirectTo: string, 11 | @Query('redirect_uri') redirectToQ: string, 12 | ) { 13 | req.logOut(); 14 | res.redirect(redirectTo || redirectToQ || '/'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/controllers/register.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, forwardRef, Get, Inject, Post, Query, Render, Req, Res, UseGuards } from '@nestjs/common'; 2 | import { RegisterDto } from '../dtos'; 3 | import { Request, Response } from 'express'; 4 | import { GuestGuard } from '../guards'; 5 | import { RegisterService } from '@app/modules/user'; 6 | 7 | @UseGuards(GuestGuard) 8 | @Controller('auth') 9 | export class RegisterController { 10 | constructor( 11 | @Inject(forwardRef(() => RegisterService)) 12 | private readonly registerService: RegisterService, 13 | ) {} 14 | 15 | @Post('register') 16 | async handleRegister( 17 | @Body() data: RegisterDto, 18 | @Query('redirect_uri') intended: string, 19 | @Req() req: Request, 20 | @Res() res: Response, 21 | ) { 22 | const user = await this.registerService.register(data); 23 | 24 | await new Promise((resolve, reject) => req.login(user, err => { 25 | if (err) { 26 | return reject(err); 27 | } 28 | resolve(); 29 | })); 30 | 31 | if (req.accepts('json')) { 32 | return res 33 | .status(201) 34 | .json({ 35 | returnTo: intended || '/', 36 | }) 37 | } 38 | 39 | res.redirect(intended || '/'); 40 | } 41 | 42 | @Get('register') 43 | @Render('index') 44 | showRegisterForm( 45 | @Req() req: Request, 46 | ) { 47 | return { 48 | csrfToken: req.csrfToken(), 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/controllers/social.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query, Req, Res, UseGuards } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { Request, Response } from 'express'; 4 | import { handleSuccessLogin } from '@app/modules/auth/utils'; 5 | 6 | @Controller('auth/social') 7 | export class SocialController { 8 | @UseGuards(AuthGuard('facebook')) 9 | @Get('/facebook') 10 | async facebookCallback( 11 | @Req() req: Request, 12 | @Res() res: Response, 13 | @Query('state') intended: string, 14 | ) { 15 | await new Promise((resolve, reject) => { 16 | req.logIn(req.user, err => (err ? reject(err) : resolve())); 17 | }); 18 | 19 | const returnTo = (intended && intended !== 'undefined') ? decodeURIComponent(intended) : '/'; 20 | 21 | return handleSuccessLogin( 22 | req, res, 23 | returnTo, 24 | false, 25 | ); 26 | } 27 | 28 | @UseGuards(AuthGuard('google')) 29 | @Get('/google') 30 | async googleCallback( 31 | @Req() req: Request, 32 | @Res() res: Response, 33 | @Query('state') intended: string, 34 | ) { 35 | await new Promise((resolve, reject) => { 36 | req.logIn(req.user, err => (err ? reject(err) : resolve())); 37 | }); 38 | 39 | const returnTo = (intended && intended !== 'undefined') ? decodeURIComponent(intended) : '/'; 40 | 41 | return handleSuccessLogin( 42 | req, res, 43 | returnTo, 44 | false, 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/decorators/access-token.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | /** 4 | * Return the current parsed access_token from the request 5 | */ 6 | export const AccessToken = createParamDecorator((_: any, ctx: ExecutionContext) => { 7 | const req = ctx.switchToHttp().getRequest(); 8 | return req.accessToken; 9 | }); 10 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/decorators/cookie.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | /** 4 | * Return a cookie or all cookies from the request 5 | */ 6 | export const Cookie = createParamDecorator((cookieName: string | undefined, ctx: ExecutionContext) => { 7 | const req = ctx.switchToHttp().getRequest(); 8 | if (cookieName) { 9 | return req.cookies[cookieName] || req.signedCookies[cookieName]; 10 | } 11 | return { 12 | ...req.cookies, 13 | ...req.signedCookies, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | 4 | /** 5 | * Return the current user from the request 6 | */ 7 | export const CurrentUser = createParamDecorator((_: any, ctx: ExecutionContext) => { 8 | if (ctx.getType<'graphql'>() === 'graphql') { 9 | const context = GqlExecutionContext.create(ctx); 10 | return context.getContext().req.user; 11 | } 12 | const req = ctx.switchToHttp().getRequest(); 13 | return req.user; 14 | }); 15 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './current-user.decorator'; 2 | export * from './cookie.decorator'; 3 | export * from './access-token.decorator'; 4 | export * from './scope.decorator'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/decorators/scope.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Scope = (...scope: string[]) => SetMetadata('scope', scope); 4 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login.dto'; 2 | export * from './register.dto'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/dtos/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsEmail, IsString, IsOptional } from 'class-validator'; 2 | 3 | /** 4 | * Login payload 5 | */ 6 | export class LoginDto { 7 | @IsNotEmpty() 8 | @IsEmail() 9 | email!: string; 10 | 11 | @IsNotEmpty() 12 | @IsString() 13 | password!: string; 14 | 15 | @IsOptional() 16 | remember?: any; 17 | } 18 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/dtos/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | import { Confirm } from '@app/utils'; 3 | 4 | /** 5 | * Register payload 6 | */ 7 | export class RegisterDto { 8 | @IsNotEmpty() 9 | @IsString() 10 | nickname: string; 11 | 12 | @IsOptional() 13 | @IsString() 14 | firstName: string; 15 | 16 | @IsOptional() 17 | @IsString() 18 | lastName: string; 19 | 20 | @IsNotEmpty() 21 | @IsEmail() 22 | email: string; 23 | 24 | @IsNotEmpty() 25 | @IsString() 26 | @Confirm() 27 | password: string; 28 | 29 | @IsNotEmpty() 30 | @IsString() 31 | passwordConfirm: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/errors/guest.exception.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom exception thrown on GuestGuard, needed to easily catch it 3 | */ 4 | export class GuestException extends Error {} 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './guest.exception' 2 | export * from './tfa-required.exception'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/errors/tfa-required.exception.ts: -------------------------------------------------------------------------------- 1 | 2 | export class TfaRequiredException extends Error {} 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/filters/forbidden-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, ForbiddenException } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | 4 | /** 5 | * The user is not authorized to perform this action 6 | * Usually thrown by a guard returning "false" 7 | */ 8 | @Catch(ForbiddenException) 9 | export class ForbiddenExceptionFilter implements ExceptionFilter { 10 | catch(exception: ForbiddenException, host: ArgumentsHost, urlOverride?: string): any { 11 | const res = host.switchToHttp().getResponse(); 12 | const req = host.switchToHttp().getRequest(); 13 | /** 14 | * If there is a user in the session, log him out 15 | */ 16 | if (req.user) { 17 | req.logout(); 18 | } 19 | /** 20 | * Redirect to the login page 21 | */ 22 | res.redirect('/auth/login?redirect_uri=' + encodeURIComponent(urlOverride || req.url)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/filters/guest-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { GuestException } from '../errors'; 3 | import { Request, Response } from 'express'; 4 | 5 | /** 6 | * Catch not guest users (ex register / login routes) 7 | * Redirect to the specified url or homepage 8 | */ 9 | @Catch(GuestException) 10 | export class GuestExceptionFilter implements ExceptionFilter { 11 | catch(exception: GuestException, host: ArgumentsHost) { 12 | const req = host.switchToHttp().getRequest(); 13 | const res = host.switchToHttp().getResponse(); 14 | 15 | res.redirect(req.query.redirect_uri || '/'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './guest-exception.filter'; 2 | export * from './forbidden-exception.filter'; 3 | export * from './tfa-exception.filter'; 4 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/filters/tfa-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { TfaRequiredException } from '@app/modules/auth/errors'; 3 | import { Request, Response } from 'express'; 4 | import { User } from '@app/entities'; 5 | 6 | @Catch(TfaRequiredException) 7 | export class TfaExceptionFilter implements ExceptionFilter { 8 | async catch(exception: TfaExceptionFilter, host: ArgumentsHost): Promise { 9 | const req = host.switchToHttp().getRequest(); 10 | const res = host.switchToHttp().getResponse(); 11 | 12 | const user: User = (req.user as any); 13 | 14 | // const otpAuthUrl = speakeasy.otpauthURL({ 15 | // secret: user.tfaSecret, 16 | // encoding: 'base32', 17 | // label: 'Argo', 18 | // }); 19 | req.session.tfaSecret = user.tfaSecret; 20 | // const dataUrl = await qrcode.toDataURL(otpAuthUrl); 21 | 22 | return res.status(206).json({ 23 | tfaRequired: true, 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/guards/authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | 3 | /** 4 | * Check if there is a valid user in the session 5 | */ 6 | @Injectable() 7 | export class AuthenticatedGuard implements CanActivate { 8 | async canActivate(context: ExecutionContext): Promise { 9 | const request = context.switchToHttp().getRequest(); 10 | return request.isAuthenticated(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/guards/guest.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | import { GuestException } from '../errors'; 4 | 5 | /** 6 | * Ensure that the user is not logged in 7 | */ 8 | @Injectable() 9 | export class GuestGuard implements CanActivate { 10 | async canActivate(context: ExecutionContext): Promise { 11 | const req = context.switchToHttp().getRequest(); 12 | 13 | if (req.user) { 14 | throw new GuestException(); 15 | } 16 | return true; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login.guard'; 2 | export * from './authenticated.guard'; 3 | export * from './guest.guard'; 4 | export * from './jwt.guard'; 5 | export * from './tfa.guard'; 6 | export * from './scope.guard'; 7 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/guards/login.guard.ts: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '@nestjs/passport'; 2 | import { ExecutionContext, Injectable } from '@nestjs/common'; 3 | import { TfaRequiredException } from '@app/modules/auth/errors'; 4 | 5 | @Injectable() 6 | export class LoginGuard extends AuthGuard('local') { 7 | async canActivate(context: ExecutionContext): Promise { 8 | const result = (await super.canActivate(context)) as boolean; 9 | const request = context.switchToHttp().getRequest(); 10 | if (request.user.tfaEnabled && request.user.tfaSecret) { 11 | throw new TfaRequiredException(); 12 | } 13 | await super.logIn(request); 14 | 15 | return result; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/guards/scope.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | import { Reflector } from '@nestjs/core'; 4 | 5 | @Injectable() 6 | export class ScopeGuard implements CanActivate { 7 | constructor( 8 | protected readonly ref: Reflector, 9 | ) {} 10 | 11 | private getScopes(context: ExecutionContext) { 12 | return [ 13 | ...(this.ref.get('scope', context.getClass()) || []), 14 | ...(this.ref.get('scope', context.getHandler()) || []), 15 | ]; 16 | } 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | const token = context.switchToHttp().getRequest().accessToken; 20 | if (!token) { 21 | return false; 22 | } 23 | const scopes = this.getScopes(context); 24 | 25 | if (!scopes.length) { 26 | return true; 27 | } 28 | 29 | return scopes.some(s => token.scopes.includes(s)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/guards/tfa.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | export class TfaGuard extends AuthGuard('otp') { 5 | async canActivate(context: ExecutionContext): Promise { 6 | const result = (await super.canActivate(context)) as boolean; 7 | const request = context.switchToHttp().getRequest(); 8 | delete request.session.tfaSecret; 9 | await super.logIn(request); 10 | 11 | return result; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.module'; 2 | export * from './decorators'; 3 | export * from './guards'; 4 | export * from './roles'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/interfaces.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface SerializedPassportSessionPayload { 3 | user: string; 4 | info: { 5 | ip: string; 6 | userAgent?: string; 7 | createdAt?: number; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/roles/index.ts: -------------------------------------------------------------------------------- 1 | import { RolesBuilder } from 'nest-access-control'; 2 | 3 | export enum Roles { 4 | ADMIN = 'ADMIN', 5 | USER = 'USER', 6 | } 7 | 8 | export const roles: RolesBuilder = new RolesBuilder(); 9 | 10 | roles.grant(Roles.USER) 11 | // User roles 12 | .grant(Roles.ADMIN) 13 | .createAny('client') 14 | .updateAny('client') 15 | .deleteAny('client') 16 | .readAny('client') 17 | 18 | .createAny('user') 19 | .updateAny('user') 20 | .deleteAny('user') 21 | .readAny('user'); 22 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/serializers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './session.serializer'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/serializers/session.serializer.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { PassportSerializer } from '@nestjs/passport'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { plainToClass } from 'class-transformer'; 6 | import { User } from '@app/entities'; 7 | 8 | /** 9 | * Specify how to serialize user data on the session cookie 10 | */ 11 | @Injectable() 12 | export class SessionSerializer extends PassportSerializer { 13 | constructor( 14 | @InjectRepository(User) 15 | private readonly userRepository: Repository, 16 | ) { 17 | super(); 18 | } 19 | 20 | /** 21 | * Serialize only the user id 22 | * @param data 23 | * @param done 24 | */ 25 | serializeUser(data: User, done: (err: Error | null, data?: string) => void): any { 26 | done(null, data.id); 27 | } 28 | 29 | /** 30 | * Retrieve the entire user from the db 31 | * @param payload 32 | * @param done 33 | */ 34 | async deserializeUser(payload: string, done: (err: Error | null, data?: any) => void): Promise { 35 | const user = await this.userRepository.findOne(payload); 36 | if (!user) { 37 | return done(new ForbiddenException('User not found')); 38 | } 39 | done(null, plainToClass(User, user)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local.strategy'; 2 | export * from './jwt.strategy'; 3 | export * from './tfa.strategy'; 4 | export * from './facebook.strategy'; 5 | export * from './google.strategy'; 6 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { User } from '@app/entities'; 5 | import { UserService } from '@app/modules/user'; 6 | import { Request } from 'express'; 7 | 8 | @Injectable() 9 | export class LocalStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | @Inject(forwardRef(() => UserService)) 12 | private readonly userService: UserService, 13 | ) { 14 | super({ 15 | usernameField: 'email', 16 | passReqToCallback: true, 17 | }); 18 | } 19 | 20 | async validate(req: Request, email: string, password: string): Promise { 21 | return await this.userService.findAndAuthenticate({ email, password }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/auth/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export function handleSuccessLogin( 4 | req: Request, 5 | res: Response, 6 | intended: string, 7 | shouldRemember: boolean 8 | ) { 9 | if (shouldRemember) { 10 | req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; 11 | } else { 12 | req.session.cookie.expires = false; 13 | } 14 | 15 | req.session.passport.info = { 16 | ip: req.ip, 17 | userAgent: req.headers['user-agent'], 18 | createdAt: Date.now(), 19 | }; 20 | 21 | if (req.accepts('html')) { 22 | return res.redirect(intended || '/'); 23 | } 24 | return res.json({ 25 | returnTo: intended || '/', 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const MAIL_QUEUE = 'MAIL_QUEUE'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/index.ts: -------------------------------------------------------------------------------- 1 | export * from './services'; 2 | export * from './mail.module'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/mail-options.factory.ts: -------------------------------------------------------------------------------- 1 | import { MailerOptions, MailerOptionsFactory } from '@nestjs-modules/mailer'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { HandlebarsAdapter } from './modules/adapter'; 5 | import { join } from "path"; 6 | 7 | @Injectable() 8 | export class MailOptionsFactory implements MailerOptionsFactory { 9 | constructor( 10 | private readonly config: ConfigService, 11 | private readonly adapter: HandlebarsAdapter, 12 | ) {} 13 | 14 | async createMailerOptions(): Promise { 15 | const config = this.config.get('mail.config'); 16 | return { 17 | ...config, 18 | template: { 19 | adapter: this.adapter, 20 | ...config.template, 21 | }, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MailService } from '@app/modules/mail/services/mail.service'; 3 | import { BullModule } from '@nestjs/bull'; 4 | import { MAIL_QUEUE } from './constants'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { MailerModule } from '@nestjs-modules/mailer'; 7 | import { MailProcessor } from './processors/mail.processor'; 8 | import { MailOptionsFactory } from './mail-options.factory'; 9 | import { AdapterModule } from '@app/modules/mail/modules/adapter'; 10 | import { SignModule } from '@app/lib/sign'; 11 | 12 | @Module({ 13 | imports: [ 14 | ConfigModule, 15 | AdapterModule, 16 | MailerModule.forRootAsync({ 17 | imports: [ConfigModule, AdapterModule], 18 | inject: [ConfigService], 19 | useClass: MailOptionsFactory, 20 | }), 21 | BullModule.registerQueueAsync({ 22 | imports: [ConfigModule], 23 | inject: [ConfigService], 24 | name: MAIL_QUEUE, 25 | useFactory: (config: ConfigService) => { 26 | return { 27 | redis: config.get('redis.ioredis'), 28 | name: MAIL_QUEUE, 29 | ...config.get('queue'), 30 | }; 31 | }, 32 | }), 33 | SignModule.register({ 34 | secret: 'secret', 35 | ttl: 60 * 60 * 24, 36 | }), 37 | ], 38 | providers: [ 39 | MailService, 40 | MailProcessor, 41 | ], 42 | exports: [ 43 | MailService, 44 | ], 45 | }) 46 | export class MailModule {} 47 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/modules/adapter/adapter.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { HandlebarsAdapter } from './handlebars.adapter'; 4 | 5 | @Module({ 6 | imports: [ 7 | ConfigModule, 8 | ], 9 | providers: [ 10 | HandlebarsAdapter, 11 | ], 12 | exports: [ 13 | HandlebarsAdapter, 14 | ], 15 | }) 16 | export class AdapterModule {} 17 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/modules/adapter/handlebars.adapter.ts: -------------------------------------------------------------------------------- 1 | import { HandlebarsAdapter as MailerHandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; 2 | import { Injectable } from '@nestjs/common'; 3 | import handlebars from 'handlebars'; 4 | import hbsLayouts from 'handlebars-layouts'; 5 | import path from 'path'; 6 | import fs from 'fs'; 7 | import { ConfigService } from '@nestjs/config'; 8 | import { MailerOptions } from '@nestjs-modules/mailer'; 9 | 10 | @Injectable() 11 | export class HandlebarsAdapter extends MailerHandlebarsAdapter { 12 | constructor( 13 | private readonly config: ConfigService, 14 | ) { 15 | super(); 16 | 17 | const partialsDirs = config.get('mail.partials'); 18 | 19 | handlebars.registerHelper(hbsLayouts(handlebars)); 20 | 21 | handlebars.registerHelper('currentYear', () => (new Date()).getFullYear()); 22 | 23 | partialsDirs.forEach(this.registerDir); 24 | } 25 | 26 | registerDir(dir: string) { 27 | fs.readdirSync(dir).forEach(file => { 28 | const name = path.parse(file).name; 29 | handlebars.registerPartial(name, fs.readFileSync(path.resolve(dir, file), 'utf8')); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/modules/adapter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './adapter.module'; 2 | export * from './handlebars.adapter'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mail.service'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/mail/services/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectQueue } from '@nestjs/bull'; 3 | import { MAIL_QUEUE } from '../constants'; 4 | import { Queue } from 'bull'; 5 | import { User } from '@app/entities'; 6 | 7 | @Injectable() 8 | export class MailService { 9 | constructor( 10 | @InjectQueue(MAIL_QUEUE) 11 | private readonly mailQueue: Queue, 12 | ) {} 13 | 14 | /** 15 | * Enqueue a Welcome email 16 | * @param payload 17 | */ 18 | async sendUserWelcome(payload: { user: User, idHash: string; emailHash: string }) { 19 | try { 20 | await this.mailQueue.add('user-welcome', payload); 21 | return true; 22 | } catch (e) { 23 | return false; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/filters/graphql.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { ApolloError } from 'apollo-server-errors'; 3 | 4 | @Catch(ApolloError) 5 | export class GraphqlFilter implements ExceptionFilter { 6 | catch(exception: ApolloError, host: ArgumentsHost): any { 7 | return exception; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, BadRequestException, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { ApolloError, toApolloError, UserInputError } from 'apollo-server-errors'; 3 | 4 | @Catch(HttpException) 5 | export class HttpExceptionFilter implements ExceptionFilter { 6 | private httpApolloMap = new Map(); 7 | 8 | constructor() { 9 | this.httpApolloMap.set(BadRequestException.name, UserInputError); 10 | } 11 | 12 | catch(exception: HttpException, host: ArgumentsHost): any { 13 | return this.toApollo(exception); 14 | } 15 | 16 | private toApollo(exception: HttpException) { 17 | const response: { 18 | message: string | string[]; 19 | statusCode: number; 20 | error: string; 21 | } | string = exception.getResponse() as any; 22 | 23 | const message = typeof response == 'string' ? response : response.message; 24 | const error = typeof response === 'string' ? exception.name : response.error; 25 | 26 | const initOptions = [ 27 | Array.isArray(message) ? message[0] : message || error, 28 | error, 29 | ]; 30 | 31 | let err: ApolloError; 32 | if (this.httpApolloMap.has(exception.name)) { 33 | err = new (this.httpApolloMap.get(exception.name))( 34 | ...initOptions, 35 | ); 36 | } else { 37 | err = new ApolloError( 38 | ...initOptions as [string, string], 39 | ); 40 | } 41 | return err; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './graphql.filter'; 2 | export * from './http-exception.filter'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/graphql.factory.ts: -------------------------------------------------------------------------------- 1 | import { GqlModuleOptions, GqlOptionsFactory, registerEnumType } from '@nestjs/graphql'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { EmailAddressResolver, JSONResolver } from 'graphql-scalars'; 4 | import { GrantTypes, ResponseModes, ResponseTypes, TokenAuthMethod } from '@app/modules/oauth2/constants'; 5 | import { Roles } from '@app/modules/auth'; 6 | 7 | @Injectable() 8 | export class GraphqlFactory implements GqlOptionsFactory { 9 | async createGqlOptions(): Promise { 10 | this.registerEnums(); 11 | 12 | return { 13 | autoSchemaFile: 'schema.graphql', 14 | path: '/api/graphql', 15 | context: this.makeContext, 16 | resolvers: { 17 | JSON: JSONResolver, 18 | EmailAddress: EmailAddressResolver, 19 | }, 20 | } 21 | } 22 | 23 | private registerEnums() { 24 | registerEnumType(GrantTypes, { 25 | name: 'GrantTypes', 26 | }); 27 | registerEnumType(ResponseTypes, { 28 | name: 'ResponseTypes', 29 | }); 30 | registerEnumType(ResponseModes, { 31 | name: 'ResponseModes', 32 | }); 33 | registerEnumType(TokenAuthMethod, { 34 | name: 'TokenAuthMethod', 35 | }); 36 | registerEnumType(Roles, { 37 | name: 'Roles', 38 | }); 39 | } 40 | 41 | makeContext = ({ req }) => { 42 | return { req } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/guards/ac.guard.ts: -------------------------------------------------------------------------------- 1 | import { ACGuard } from 'nest-access-control'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { ForbiddenError } from 'apollo-server-errors'; 5 | 6 | export class AcGuard extends ACGuard { 7 | protected async getUser(context: ExecutionContext): Promise { 8 | const ctx = GqlExecutionContext.create(context); 9 | return ctx.getContext().req.user; 10 | } 11 | 12 | protected async getUserRoles(context: ExecutionContext): Promise { 13 | const user = await this.getUser(context); 14 | return user.role; 15 | } 16 | 17 | async canActivate(context: ExecutionContext): Promise { 18 | const can = await super.canActivate(context); 19 | if (!can) { 20 | throw new ForbiddenError('cannot access requested resource'); 21 | } 22 | return can; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | 4 | export class AuthGuard implements CanActivate { 5 | async canActivate(context: ExecutionContext): Promise { 6 | const ctx = GqlExecutionContext.create(context); 7 | return ctx.getContext().req.isAuthenticated(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.guard'; 2 | export * from './ac.guard'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './management-api.module'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/inputs/client-meta.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class ClientMetaInput { 5 | @Field({ nullable: true }) 6 | description: string; 7 | 8 | @Field({ nullable: true }) 9 | logo_uri: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/inputs/create-client.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { ClientMetaInput } from './client-meta.input'; 3 | 4 | @InputType() 5 | export class CreateClientInput { 6 | @Field() 7 | name: string; 8 | 9 | @Field(returns => ClientMetaInput, { nullable: true }) 10 | meta: ClientMetaInput; 11 | 12 | @Field(returns => [String]) 13 | redirect: string[]; 14 | 15 | @Field({ defaultValue: false }) 16 | firstParty: boolean; 17 | } 18 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/inputs/create-user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { EmailAddressResolver } from 'graphql-scalars'; 3 | import { Confirm } from '@app/utils'; 4 | import { Roles } from '@app/modules/auth'; 5 | 6 | @InputType() 7 | export class CreateUserInput { 8 | @Field() 9 | nickname: string; 10 | 11 | @Field({ nullable: true }) 12 | firstName: string; 13 | 14 | @Field({ nullable: true }) 15 | lastName: string; 16 | 17 | @Field(returns => EmailAddressResolver) 18 | email: string; 19 | 20 | @Field() 21 | @Confirm() 22 | password: string; 23 | 24 | @Field() 25 | passwordConfirm: string; 26 | 27 | @Field(returns => Roles, { defaultValue: Roles.USER }) 28 | role: Roles; 29 | } 30 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/inputs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-client.input'; 2 | export * from './update-client.input'; 3 | 4 | export * from './create-user.input'; 5 | export * from './update-user.input'; 6 | 7 | export * from './update-current-user.input'; 8 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/inputs/update-client.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { ClientMetaInput } from './client-meta.input'; 3 | 4 | @InputType() 5 | export class UpdateClientInput { 6 | @Field({ nullable: true }) 7 | name: string; 8 | 9 | @Field(returns => ClientMetaInput, { nullable: true }) 10 | meta: ClientMetaInput; 11 | 12 | @Field(returns => [String], { nullable: true }) 13 | redirect: string[]; 14 | 15 | @Field(returns => [String], { nullable: true }) 16 | grantTypes: string[]; 17 | 18 | @Field(returns => [String], { nullable: true }) 19 | responseTypes: string[]; 20 | 21 | @Field(returns => [String], { nullable: true }) 22 | responseModes: string[]; 23 | 24 | @Field(returns => [String], { nullable: true }) 25 | authMethods: string[]; 26 | 27 | @Field(returns => Boolean, { nullable: true }) 28 | firstParty: boolean; 29 | 30 | @Field(returns => [String], { nullable: true }) 31 | scopes: string[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/inputs/update-current-user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { EmailAddressResolver } from 'graphql-scalars'; 3 | import { IsNotEmpty, IsOptional, ValidateIf } from 'class-validator'; 4 | import { Confirm } from '@app/utils'; 5 | 6 | @InputType() 7 | export class UpdateCurrentUserInput { 8 | @Field({ nullable: true }) 9 | nickname: string; 10 | 11 | @Field({ nullable: true }) 12 | firstName: string; 13 | 14 | @Field({ nullable: true }) 15 | lastName: string; 16 | 17 | @Field(returns => EmailAddressResolver, { nullable: true }) 18 | email: string; 19 | 20 | @IsOptional() 21 | @Confirm() 22 | @Field({ nullable: true }) 23 | password: string; 24 | 25 | @ValidateIf((o: UpdateCurrentUserInput) => !!o.password) 26 | @IsNotEmpty() 27 | @Field({ nullable: true }) 28 | passwordConfirm: string; 29 | 30 | @ValidateIf((o: UpdateCurrentUserInput) => !!o.password) 31 | @IsNotEmpty() 32 | @Field({ nullable: true }) 33 | currentPassword: string; 34 | } 35 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/inputs/update-user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { EmailAddressResolver } from 'graphql-scalars'; 3 | import { Roles } from '@app/modules/auth'; 4 | 5 | @InputType() 6 | export class UpdateUserInput { 7 | @Field({ nullable: true }) 8 | nickname: string; 9 | 10 | @Field({ nullable: true }) 11 | firstName: string; 12 | 13 | @Field({ nullable: true }) 14 | lastName: string; 15 | 16 | @Field(returns => EmailAddressResolver, { nullable: true }) 17 | email: string; 18 | 19 | @Field(returns => Roles, { nullable: true }) 20 | role: Roles; 21 | } 22 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/management-api.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { OAuthClient, User } from '@app/entities'; 4 | import { GraphQLModule } from '@nestjs/graphql'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { ClientResolver, CurrentUserResolver, DashboardResolver, UserResolver } from './resolvers'; 7 | import { GraphqlFactory } from './graphql.factory'; 8 | import { AccessControlModule } from 'nest-access-control'; 9 | import { roles } from '@app/modules/auth'; 10 | import { APP_FILTER } from '@nestjs/core'; 11 | import { GraphqlFilter } from '@app/modules/management-api/filters'; 12 | import { SessionService, TfaService } from '@app/modules/management-api/services'; 13 | import { UserModule } from '@app/modules/user'; 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule, 18 | UserModule, 19 | AccessControlModule.forRoles(roles), 20 | TypeOrmModule.forFeature([ 21 | OAuthClient, User, 22 | ]), 23 | GraphQLModule.forRootAsync({ 24 | imports: [ConfigModule], 25 | inject: [ConfigService], 26 | useClass: GraphqlFactory, 27 | }), 28 | ], 29 | providers: [ 30 | SessionService, 31 | TfaService, 32 | ClientResolver, 33 | UserResolver, 34 | CurrentUserResolver, 35 | DashboardResolver, 36 | { 37 | provide: APP_FILTER, 38 | useClass: GraphqlFilter, 39 | }, 40 | ], 41 | }) 42 | export class ManagementApiModule {} 43 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/resolvers/dashboard.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Int, Query, Resolver } from '@nestjs/graphql'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { OAuthClient, User } from '@app/entities'; 4 | import { MoreThanOrEqual, Repository } from 'typeorm'; 5 | 6 | @Resolver() 7 | export class DashboardResolver { 8 | constructor( 9 | @InjectRepository(OAuthClient) 10 | private readonly clientRepository: Repository, 11 | @InjectRepository(User) 12 | private readonly userRepository: Repository, 13 | ) {} 14 | 15 | @Query(returns => Int) 16 | async usersCount() { 17 | return this.userRepository.count(); 18 | } 19 | 20 | @Query(returns => Int) 21 | async clientsCount() { 22 | return this.clientRepository.count(); 23 | } 24 | 25 | @Query(returns => Int) 26 | async newSignUps( 27 | @Args({ name: 'since', type: () => Date }) since: Date, 28 | ) { 29 | return this.userRepository.count({ 30 | createdAt: MoreThanOrEqual(since), 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client.resolver'; 2 | export * from './user.resolver'; 3 | export * from './current-user.resolver'; 4 | export * from './dashboard.resolver'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './session.service'; 2 | export * from './tfa.service'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './session'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/types/pagination-info.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class PaginationInfo { 5 | @Field(returns => Boolean) 6 | hasMore: boolean; 7 | 8 | @Field(returns => Int) 9 | total: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/types/session.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class Session { 5 | @Field(returns => ID) 6 | sessionId: string; 7 | 8 | @Field() 9 | ip: string; 10 | 11 | @Field({ nullable: true }) 12 | userAgent?: string; 13 | 14 | @Field({ nullable: true }) 15 | os?: string; 16 | 17 | @Field({ nullable: true }) 18 | browser?: string; 19 | 20 | @Field({ nullable: true }) 21 | createdAt: Date; 22 | } 23 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/management-api/types/users-paginated.response.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from '@nestjs/graphql'; 2 | import { User } from '@app/entities'; 3 | import { PaginationInfo } from '@app/modules/management-api/types/pagination-info'; 4 | 5 | @ObjectType() 6 | export class UsersPaginatedResponse { 7 | @Field(returns => [User]) 8 | items: User[]; 9 | 10 | @Field(returns => PaginationInfo) 11 | paginationInfo: PaginationInfo; 12 | } 13 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/auth.request.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient, User } from '@app/entities'; 2 | import { GrantTypes, ResponseModes, ResponseTypes } from './constants'; 3 | 4 | export class AuthRequest { 5 | public user: User; 6 | public approved: boolean = false; 7 | public readonly codeChallenge: any; 8 | public readonly codeChallengeMethod: any; 9 | public readonly grantTypeId: GrantTypes; 10 | public readonly client: OAuthClient; 11 | public readonly redirectUri: string; 12 | public scopes: string[]; 13 | public readonly state: string; 14 | public readonly responseMode: ResponseModes = ResponseModes.query; 15 | public readonly responseType: ResponseTypes = ResponseTypes.code; 16 | 17 | constructor(partial: Partial) { 18 | Object.assign(this, partial); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum GrantTypes { 3 | password = 'password', 4 | authorization_code = 'authorization_code', 5 | refresh_token = 'refresh_token', 6 | client_credentials = 'client_credentials', 7 | } 8 | 9 | export const grantsWithRefreshToken = [ 10 | GrantTypes.password, 11 | GrantTypes.authorization_code, 12 | GrantTypes.refresh_token, 13 | GrantTypes.client_credentials, 14 | ]; 15 | 16 | export const grantsWithIdToken = [ 17 | GrantTypes.password, 18 | GrantTypes.authorization_code, 19 | GrantTypes.refresh_token, 20 | ]; 21 | 22 | export enum ResponseModes { 23 | query = 'query', 24 | fragment = 'fragment', 25 | form_post = 'form_post', 26 | } 27 | 28 | export enum ResponseTypes { 29 | code = 'code', 30 | } 31 | 32 | export enum PromptTypes { 33 | none = 'none', 34 | login = 'login', 35 | consent = 'consent', 36 | } 37 | 38 | export enum DisplayTypes { 39 | page = 'page', 40 | popup = 'popup', 41 | } 42 | 43 | export enum Scopes { 44 | openid = 'openid', 45 | email = 'email', 46 | profile = 'profile', 47 | offline_access = 'offline_access', 48 | } 49 | 50 | export enum ApiScopes { 51 | users_profile = 'users:profile', 52 | users_email = 'users:email', 53 | } 54 | 55 | export enum TokenAuthMethod { 56 | client_secret_post = 'client_secret_post', 57 | client_secret_basic = 'client_secret_basic', 58 | none = 'none', 59 | } 60 | 61 | export enum TokenType { 62 | access_token = 'access_token', 63 | refresh_token = 'refresh_token', 64 | } 65 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/controllers/debug.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { OAuthClient } from '../../../entities'; 4 | import { Repository } from 'typeorm'; 5 | 6 | @Controller('debug') 7 | export class DebugController { 8 | constructor( 9 | @InjectRepository(OAuthClient) 10 | private readonly clientRepository: Repository, 11 | ) {} 12 | 13 | @Get('clients') 14 | clients() { 15 | return this.clientRepository.find(); 16 | } 17 | 18 | @Post('clients') 19 | createClient( 20 | @Body() data: any, 21 | ) { 22 | return this.clientRepository.save( 23 | this.clientRepository.create(data), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token.controller'; 2 | export * from './authorize.controller'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/dto/authorize.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsNotEmpty, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator'; 2 | import { PromptTypes, ResponseModes, ResponseTypes } from '../constants'; 3 | 4 | export class AuthorizeDto { 5 | @IsNotEmpty() 6 | @IsString() 7 | client_id: string; 8 | 9 | @IsOptional() 10 | @IsString() 11 | scope: string; 12 | 13 | @IsNotEmpty() 14 | @IsString() 15 | @MinLength(8) 16 | state: string; 17 | 18 | @IsOptional() 19 | @IsString() 20 | code_challenge: string; 21 | 22 | @ValidateIf((o: AuthorizeDto) => !!o.code_challenge) 23 | @IsNotEmpty() 24 | @IsString() 25 | code_challenge_method: string; 26 | 27 | @IsOptional() 28 | @IsEnum(ResponseModes, { message: `response_mode must be one of: ${Object.values(ResponseModes)}` }) 29 | response_mode: ResponseModes = ResponseModes.query; 30 | 31 | @IsNotEmpty() 32 | @IsEnum(ResponseTypes, { message: `response_type must be one of: ${Object.values(ResponseTypes)}` }) 33 | response_type: ResponseTypes; 34 | 35 | @IsNotEmpty() 36 | @IsString() 37 | redirect_uri: string; 38 | 39 | @IsOptional() 40 | @IsEnum(PromptTypes, { message: `prompt must be one of: ${Object.values(PromptTypes)}` }) 41 | prompt: PromptTypes = PromptTypes.consent; 42 | 43 | get scopes() { 44 | return (this.scope || '').split(' ').filter(Boolean); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/dto/consent.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class ConsentDto { 4 | @IsOptional() 5 | @IsArray() 6 | @IsString({ each: true }) 7 | scopes: string[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token.dto'; 2 | export * from './authorize.dto'; 3 | export * from './consent.dto'; 4 | export * from './introspect.dto'; 5 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/dto/introspect.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | import { ResponseModes, TokenType } from '@app/modules/oauth2/constants'; 3 | 4 | export class IntrospectDto { 5 | @IsNotEmpty() 6 | @IsString() 7 | token: string; 8 | 9 | @IsOptional() 10 | @IsString() 11 | client_id?: string; 12 | 13 | @IsOptional() 14 | @IsString() 15 | client_secret?: string; 16 | 17 | @IsOptional() 18 | @IsEnum(TokenType, { message: `token_type_hint must be one of: ${Object.values(TokenType)}` }) 19 | token_type_hint?: TokenType; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/dto/token.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator'; 2 | import { GrantTypes } from '../constants'; 3 | 4 | export class TokenDto { 5 | @IsNotEmpty() 6 | @IsString() 7 | @IsEnum(GrantTypes, { message: `grant_type must be one of: ${Object.values(GrantTypes)}` }) 8 | grant_type: GrantTypes; 9 | 10 | @ValidateIf((o: TokenDto) => o.grant_type === GrantTypes.password) 11 | @IsNotEmpty() 12 | @IsEmail() 13 | username: string; 14 | 15 | @ValidateIf((o: TokenDto) => o.grant_type === GrantTypes.password) 16 | @IsNotEmpty() 17 | @IsString() 18 | password: string; 19 | 20 | @ValidateIf((o: TokenDto) => o.grant_type === GrantTypes.authorization_code) 21 | @IsNotEmpty() 22 | @IsString() 23 | code: string; 24 | 25 | @ValidateIf((o: TokenDto) => o.grant_type === GrantTypes.authorization_code) 26 | @IsNotEmpty() 27 | @IsString() 28 | redirect_uri: string; 29 | 30 | @IsOptional() 31 | @IsString() 32 | code_verifier: string; 33 | 34 | @ValidateIf((o: TokenDto) => o.grant_type === GrantTypes.refresh_token) 35 | @IsNotEmpty() 36 | @IsString() 37 | refresh_token: string; 38 | 39 | @IsOptional() 40 | @IsString() 41 | client_id: string; 42 | 43 | @IsOptional() 44 | @IsString() 45 | client_secret: string; 46 | 47 | @IsOptional() 48 | @IsString() 49 | scope: string; 50 | 51 | get scopes() { 52 | return (this.scope || '').split(' ').filter(Boolean); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './o-auth.exception'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/filters/RFC6749-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; 2 | import { OAuthException } from '@app/modules/oauth2/errors'; 3 | import { Response } from 'express'; 4 | 5 | @Catch(HttpException) 6 | export class RFC6749ExceptionFilter implements ExceptionFilter { 7 | catch(exception: HttpException, host: ArgumentsHost): any { 8 | let oAuthException: OAuthException; 9 | if (!(exception instanceof OAuthException)) { 10 | const [error, description] = this.getErrorAndDescription(exception); 11 | 12 | oAuthException = new OAuthException( 13 | error, 14 | description, 15 | exception.getStatus(), 16 | ); 17 | } else { 18 | oAuthException = exception; 19 | } 20 | 21 | const res = host.switchToHttp().getResponse(); 22 | 23 | return res 24 | .status(oAuthException.getStatus()) 25 | .json(oAuthException.getResponse()); 26 | } 27 | 28 | private getErrorAndDescription(exception: HttpException) { 29 | const response = exception.getResponse(); 30 | 31 | if (typeof response === 'string') { 32 | const error = response; 33 | const description = exception.message; 34 | return [ 35 | error, 36 | description !== error ? description : undefined, 37 | ]; 38 | } 39 | if (Array.isArray((response as any).message)) { // Validation error 40 | return [ 41 | 'invalid_request', 42 | (response as any).message[0], 43 | ]; 44 | } 45 | return [ 46 | (response as any).message || exception.message, 47 | (response as any).error, 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './o-auth-exception.filter'; 2 | export * from './RFC6749-exception.filter'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/filters/o-auth-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { OAuthException } from '../errors'; 3 | import { Request, Response } from 'express'; 4 | import { handleResponseMode } from '../utils'; 5 | import { ResponseModes } from '../constants'; 6 | 7 | @Catch(OAuthException) 8 | export class OAuthExceptionFilter implements ExceptionFilter { 9 | catch(exception: OAuthException, host: ArgumentsHost): any { 10 | const req = host.switchToHttp().getRequest(); 11 | const res = host.switchToHttp().getResponse(); 12 | 13 | if (req.query.response_mode) { 14 | return handleResponseMode( 15 | res, 16 | req.query.response_mode as ResponseModes, 17 | req.query.redirect_uri as string, 18 | exception.getResponse() as Record, 19 | ); 20 | } 21 | 22 | // TODO support multiple content-types 23 | 24 | return res 25 | .status(exception.getStatus()) 26 | .json(exception.getResponse()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/guards/authorize.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthenticatedGuard } from '@app/modules/auth/guards'; 3 | import { AuthorizeDto } from '../dto'; 4 | import { Request } from 'express'; 5 | import { PromptTypes } from '../constants'; 6 | 7 | /** 8 | * Guard to /authorize endpoint 9 | */ 10 | @Injectable() 11 | export class AuthorizeGuard extends AuthenticatedGuard { 12 | async canActivate(context: ExecutionContext): Promise { 13 | const req = context.switchToHttp().getRequest>(); 14 | const { query } = req; 15 | /** 16 | * If prompt=login we must show the login form again 17 | * We log out the user, thus the parent class will throw a ForbiddenException, 18 | * catch (@see AuthorizeForbiddenExceptionFilter) to redirect to the login page 19 | */ 20 | if (query.prompt === PromptTypes.login) { 21 | req.logout(); 22 | } 23 | 24 | return super.canActivate(context); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/guards/client-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Inject, mixin, Type } from '@nestjs/common'; 2 | import { ClientService } from '@app/modules/oauth2/modules'; 3 | import { Request } from 'express'; 4 | 5 | export type IClientAuthGuard = CanActivate; 6 | 7 | export const ClientAuthGuard: ( 8 | validateSecret?: boolean, 9 | ) => Type = createClientAuthGuard; 10 | 11 | function createClientAuthGuard(validateSecret = true): Type { 12 | class MixinClientAuthGuard implements CanActivate { 13 | constructor( 14 | @Inject(ClientService) 15 | private readonly clientService: ClientService, 16 | ) {} 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | const req = context.switchToHttp().getRequest(); 20 | const isGet = req.method === 'GET'; 21 | const client = await this.clientService.getClient( 22 | this.clientService.getClientCredentials( 23 | isGet ? req.query : req.body, 24 | !isGet && req.headers 25 | ), 26 | validateSecret, 27 | ); 28 | // Cache retrieved client on request 29 | req.client = client; 30 | 31 | return !!client; 32 | } 33 | } 34 | 35 | const guard = mixin(MixinClientAuthGuard); 36 | return guard; 37 | } 38 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authorize.guard'; 2 | export * from './client-auth.guard'; 3 | export * from './pkce.guard'; 4 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/src/apps/oauth2/modules/oauth2/index.ts -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import { OAuthAccessToken, OAuthRefreshToken, User } from '../../../entities'; 2 | import { TokenAuthMethod } from '@app/modules/oauth2/constants'; 3 | 4 | export type CredentialTuple = [string | null, string | null]; 5 | 6 | export interface ClientCredentials { 7 | clientId: string; 8 | clientSecret: string; 9 | type: TokenAuthMethod; 10 | } 11 | 12 | export interface AccessTokenRequestResponse { 13 | accessToken: OAuthAccessToken; 14 | refreshToken?: OAuthRefreshToken; 15 | user?: User; 16 | scopes?: string[]; 17 | } 18 | 19 | export interface AccessTokenResponse { 20 | type: 'Bearer'; 21 | expires_in: number; 22 | access_token?: string; 23 | refresh_token?: string; 24 | id_token?: string; 25 | } 26 | 27 | export interface AccessTokenJwtPayload { 28 | aud: string; 29 | jti: string; 30 | exp: number; 31 | sub: string; 32 | scopes: string[]; 33 | iss?: string; 34 | } 35 | 36 | export interface RefreshTokenData { 37 | id: string; 38 | accessTokenId: string; 39 | expiresAt: number; 40 | } 41 | 42 | export interface IdTokenJwtPayload { 43 | aud: string; 44 | iat: number; 45 | nbf: number; 46 | exp: number; 47 | sub: string; 48 | email?: string; 49 | [key: string]: any; 50 | } 51 | 52 | export interface AuthCodeData { 53 | id: string; 54 | expiresAt: number; 55 | codeChallenge?: string; 56 | codeChallengeMethod?: string; 57 | } 58 | 59 | export interface AuthCodeResponse { 60 | code: string; 61 | returnTo: string; 62 | state: string; 63 | } 64 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/authorization-code/authorization-code.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommonModule } from '../common'; 3 | import { AuthCodeService, AuthorizationCodeServiceGrant } from './services'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { OAuthCode } from '@app/entities'; 6 | 7 | @Module({ 8 | imports: [ 9 | CommonModule, 10 | TypeOrmModule.forFeature([ 11 | OAuthCode, 12 | ]), 13 | ], 14 | providers: [ 15 | AuthCodeService, 16 | AuthorizationCodeServiceGrant, 17 | ], 18 | exports: [ 19 | AuthCodeService, 20 | AuthorizationCodeServiceGrant, 21 | ], 22 | }) 23 | export class AuthorizationCodeModule {} 24 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/authorization-code/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authorization-code.module'; 2 | export * from './services'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/authorization-code/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authorization-code.service-grant'; 2 | export * from './auth-code.service'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/client-credentials/client-credentials.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommonModule } from '../common'; 3 | import { ClientCredentialsServiceGrant } from './services'; 4 | 5 | @Module({ 6 | imports: [ 7 | CommonModule, 8 | ], 9 | providers: [ 10 | ClientCredentialsServiceGrant, 11 | ], 12 | exports: [ 13 | ClientCredentialsServiceGrant, 14 | ], 15 | }) 16 | export class ClientCredentialsModule {} 17 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/client-credentials/index.ts: -------------------------------------------------------------------------------- 1 | export * from './services'; 2 | export * from './client-credentials.module'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/client-credentials/services/client-credentials.service-grant.ts: -------------------------------------------------------------------------------- 1 | import { AbstractGrant, InjectableGrant } from '../../common'; 2 | import { GrantTypes } from '@app/modules/oauth2/constants'; 3 | import { Request } from 'express'; 4 | import { TokenDto } from '@app/modules/oauth2/dto'; 5 | import { AccessTokenRequestResponse } from '@app/modules/oauth2/interfaces'; 6 | 7 | @InjectableGrant(GrantTypes.client_credentials) 8 | export class ClientCredentialsServiceGrant extends AbstractGrant { 9 | async respondToAccessTokenRequest(req: Request, body: TokenDto): Promise { 10 | const client = await this.getClient(body, req); 11 | 12 | return this.connection.transaction(async em => 13 | this.returnAccessTokenResponse({ em, client, body }), 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/client-credentials/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client-credentials.service-grant'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const GRANT_TYPE_METADATA = '__GRANT_TYPE_METADATA__'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './injectable.grant.decorator'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/decorators/injectable.grant.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Injectable, InjectableOptions } from '@nestjs/common'; 2 | import { GRANT_TYPE_METADATA } from '../constants'; 3 | import { GrantTypes } from '@app/modules/oauth2/constants'; 4 | 5 | function Grant(grantType: GrantTypes): ClassDecorator { 6 | return (target: object) => { 7 | Reflect.defineMetadata(GRANT_TYPE_METADATA, grantType, target); 8 | }; 9 | } 10 | 11 | export const InjectableGrant = (grantType: GrantTypes, options?: InjectableOptions) => applyDecorators( 12 | Injectable(options), 13 | Grant(grantType), 14 | ); 15 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/grant.interface.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { AuthorizeDto, TokenDto } from '@app/modules/oauth2/dto'; 3 | import { OAuthAccessToken, OAuthClient, OAuthCode, OAuthRefreshToken } from '@app/entities'; 4 | import { AccessTokenRequestResponse } from '@app/modules/oauth2/interfaces'; 5 | import { GrantTypes } from '@app/modules/oauth2/constants'; 6 | import { AuthRequest } from '@app/modules/oauth2/auth.request'; 7 | 8 | export interface GrantInterface { 9 | getIdentifier(): GrantTypes; 10 | 11 | canRespondToAuthorizationRequest(query: Record): boolean; 12 | 13 | respondToAccessTokenRequest(req: Request, body: TokenDto): Promise; 14 | 15 | issueAccessToken( 16 | client: OAuthClient, 17 | userId: string | null, 18 | scopes: string[] 19 | ): Promise; 20 | 21 | issueRefreshToken(accessToken: OAuthAccessToken): Promise; 22 | 23 | createAuthRequest(data: AuthorizeDto): Promise; 24 | 25 | completeAuthRequest(authRequest: AuthRequest): Promise; 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/grant.scanner.ts: -------------------------------------------------------------------------------- 1 | import { ModuleRef, NestContainer } from '@nestjs/core'; 2 | import { GRANT_TYPE_METADATA } from './constants'; 3 | import { GrantInterface } from './grant.interface'; 4 | 5 | export class GrantScanner { 6 | private getModules(modulesContainer: Map): any[] { 7 | return [...modulesContainer.values()]; 8 | } 9 | 10 | public scan( 11 | moduleRef: ModuleRef, 12 | ): Map { 13 | const map = new Map(); 14 | const container: NestContainer = (moduleRef as any).container; 15 | const modules = this.getModules( 16 | container.getModules(), 17 | ); 18 | modules.forEach(m => { 19 | m._providers.forEach(p => { 20 | const { metatype, name } = p; 21 | 22 | if (typeof metatype !== 'function') { 23 | return; 24 | } 25 | if (!p.instance) { 26 | return; 27 | } 28 | 29 | const dataSourceMetadata = Reflect.getMetadata( 30 | GRANT_TYPE_METADATA, 31 | p.instance.constructor, 32 | ); 33 | if (!dataSourceMetadata) { 34 | return; 35 | } 36 | p.instance.constructor.prototype.getIdentifier = () => dataSourceMetadata; 37 | map.set(name, moduleRef.get(name, { strict: false })); 38 | }); 39 | }); 40 | 41 | return map; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './services'; 2 | export * from './common.module'; 3 | export * from './grant.interface'; 4 | export * from './abstract.grant'; 5 | export * from './decorators'; 6 | export * from './grant.scanner'; 7 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/services/access-token.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { OAuthAccessToken } from '@app/entities'; 4 | import { Repository } from 'typeorm'; 5 | import { GrantTypes } from '@app/modules/oauth2/constants'; 6 | import { BaseTokenService } from './base-token.service'; 7 | 8 | @Injectable() 9 | export class AccessTokenService extends BaseTokenService { 10 | constructor( 11 | @InjectRepository(OAuthAccessToken) 12 | protected readonly repository: Repository, 13 | ) { 14 | super(repository); 15 | } 16 | 17 | create( 18 | repo = this.repository, 19 | clientId: string, 20 | userId: string | null, 21 | scopes: string[], 22 | grantType: GrantTypes, 23 | ttl: number, 24 | ) { 25 | const accessToken = repo.create({ 26 | clientId, 27 | userId, 28 | scopes, 29 | revoked: false, 30 | grantType, 31 | expiresAt: this.getExpiration(ttl), 32 | }); 33 | return repo.save(accessToken); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/services/base-token.service.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { Repository } from 'typeorm'; 3 | import { BaseEntity } from '@app/entities/base.entity'; 4 | 5 | export abstract class BaseTokenService { 6 | protected readonly logger = new Logger(this.constructor.name); 7 | 8 | protected constructor( 9 | protected readonly repository: Repository, 10 | ) {} 11 | 12 | public revoke( 13 | repo = this.repository, 14 | token: E, 15 | ): Promise { 16 | token.revoked = true; 17 | return repo.save(token as any) as unknown as Promise; 18 | } 19 | 20 | protected getExpiration(ttl: number) { 21 | const expiration = new Date(); 22 | expiration.setUTCSeconds(expiration.getUTCSeconds() + ttl); 23 | return expiration; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client.service'; 2 | export * from './access-token.service'; 3 | export * from './refresh-token.service'; 4 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/token/index.ts: -------------------------------------------------------------------------------- 1 | export * from './strategies'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/token/strategies/aes.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { CipherService } from '@app/lib/cipher'; 4 | import { TokenStrategy } from './strategy'; 5 | 6 | @Injectable() 7 | export class AesStrategy implements TokenStrategy { 8 | constructor( 9 | private readonly config: ConfigService, 10 | private readonly cipherService: CipherService, 11 | ) {} 12 | 13 | async sign(payload: any): Promise { 14 | return this.cipherService.encrypt({ 15 | ...payload, 16 | iss: this.config.get('app.appUrl'), 17 | }); 18 | } 19 | 20 | async verify

(encrypted: string): Promise

{ 21 | return this.cipherService.decrypt(encrypted); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/token/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './strategy'; 2 | export * from './jwt.strategy'; 3 | export * from './aes.strategy'; 4 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/token/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { TokenStrategy } from './strategy'; 3 | import { JwtService } from '@app/lib/jwt'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | @Injectable() 7 | export class JwtStrategy implements TokenStrategy { 8 | constructor( 9 | private readonly config: ConfigService, 10 | private readonly jwtService: JwtService, 11 | ) {} 12 | 13 | sign(payload: any): Promise { 14 | return this.jwtService.sign({ 15 | ...payload, 16 | iss: this.config.get('app.appUrl'), 17 | }, 'access_token'); 18 | } 19 | 20 | verify

(encrypted: string): Promise

{ 21 | return this.jwtService.verify(encrypted, 'access_token'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/common/token/strategies/strategy.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface TokenStrategy { 3 | sign(payload: any): Promise; 4 | 5 | verify

(encrypted: string): Promise

; 6 | } 7 | 8 | export const TOKEN_STRATEGY = 'TOKEN_STRATEGY'; 9 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './client-credentials'; 3 | export * from './password'; 4 | export * from './refresh-token'; 5 | export * from './authorization-code'; 6 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/password/index.ts: -------------------------------------------------------------------------------- 1 | export * from './services'; 2 | export * from './password.module'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/password/password.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommonModule } from '../common'; 3 | import { PasswordServiceGrant } from './services'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from '@app/entities'; 6 | 7 | @Module({ 8 | imports: [ 9 | CommonModule, 10 | TypeOrmModule.forFeature([User]), 11 | ], 12 | providers: [ 13 | PasswordServiceGrant, 14 | ], 15 | exports: [ 16 | PasswordServiceGrant, 17 | ], 18 | }) 19 | export class PasswordModule {} 20 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/password/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './password.service-grant'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/refresh-token/index.ts: -------------------------------------------------------------------------------- 1 | export * from './refresh-token.module'; 2 | export * from './services'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/refresh-token/refresh-token.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommonModule } from '../common'; 3 | import { RefreshTokenServiceGrant } from './services'; 4 | 5 | @Module({ 6 | imports: [ 7 | CommonModule, 8 | ], 9 | providers: [ 10 | RefreshTokenServiceGrant, 11 | ], 12 | exports: [ 13 | RefreshTokenServiceGrant, 14 | ], 15 | }) 16 | export class RefreshTokenModule {} 17 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/modules/refresh-token/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './refresh-token.service-grant'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/oauth2.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CodeService, TokenService } from './services'; 3 | import { AuthorizeController, TokenController } from './controllers'; 4 | import { 5 | CommonModule, 6 | ClientCredentialsModule, 7 | PasswordModule, 8 | RefreshTokenModule, 9 | AuthorizationCodeModule, 10 | } from './modules'; 11 | import { TypeOrmModule } from '@nestjs/typeorm'; 12 | import { OAuthAccessToken, OAuthClient, OAuthRefreshToken } from '@app/entities'; 13 | import { DebugController } from '@app/modules/oauth2/controllers/debug.controller'; 14 | 15 | @Module({ 16 | imports: [ 17 | CommonModule, 18 | ClientCredentialsModule, 19 | PasswordModule, 20 | RefreshTokenModule, 21 | AuthorizationCodeModule, 22 | TypeOrmModule.forFeature([ 23 | OAuthAccessToken, 24 | OAuthRefreshToken, 25 | OAuthClient, 26 | ]), 27 | ], 28 | providers: [ 29 | TokenService, 30 | CodeService, 31 | ], 32 | controllers: [ 33 | TokenController, 34 | AuthorizeController, 35 | DebugController, 36 | ], 37 | }) 38 | export class OAuth2Module {} 39 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/oauth2/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token.service'; 2 | export * from './code.service'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './well-known.controller'; 2 | export * from './user-info.controller'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/controllers/user-info.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, UseGuards } from '@nestjs/common'; 2 | import { AccessToken, CurrentUser, JwtGuard } from '@app/modules/auth'; 3 | import { User } from '@app/entities'; 4 | import { AccessTokenJwtPayload } from '@app/modules/oauth2/interfaces'; 5 | import { Scopes } from '@app/modules/oauth2/constants'; 6 | 7 | @Controller('userinfo') 8 | export class UserInfoController { 9 | @UseGuards(JwtGuard) 10 | @Get() 11 | @Post() 12 | async userInfo( 13 | @CurrentUser() user: User, 14 | @AccessToken() token: AccessTokenJwtPayload, 15 | ) { 16 | return user.toOpenIdProfile((token.scopes || []) as Scopes[]); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/controllers/well-known.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { OpenIdService } from '../services'; 3 | import { JwtService } from '@app/lib/jwt'; 4 | 5 | @Controller('.well-known') 6 | export class WellKnownController { 7 | constructor( 8 | private readonly openIdService: OpenIdService, 9 | private readonly jwtService: JwtService, 10 | ) {} 11 | 12 | @Get('openid-configuration') 13 | getOpenIdConfig() { 14 | return this.openIdService.getConfig(); 15 | } 16 | 17 | @Get('jwks.json') 18 | getJwks() { 19 | return this.jwtService.jwks(undefined, 'public'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/src/apps/oauth2/modules/openid/index.ts -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/open-id-config.ts: -------------------------------------------------------------------------------- 1 | import { GrantTypes, ResponseModes, ResponseTypes, Scopes, TokenAuthMethod } from '@app/modules/oauth2/constants'; 2 | 3 | export class OpenIdConfig { 4 | private readonly response_types_supported: ResponseTypes[] = Object.values(ResponseTypes); 5 | private readonly grant_types_supported: GrantTypes[] = Object.values(GrantTypes); 6 | private readonly response_modes_supported: ResponseModes[] = Object.values(ResponseModes); 7 | private readonly scopes_supported: Scopes[] = Object.values(Scopes); 8 | private readonly token_endpoint_auth_methods_supported: TokenAuthMethod[] = Object.values(TokenAuthMethod); 9 | private readonly subject_types_supported: string[] = ['public']; 10 | 11 | issuer: string; 12 | authorization_endpoint: string; 13 | registration_endpoint: string; 14 | token_endpoint: string; 15 | jwks_uri: string; 16 | userinfo_endpoint: string; 17 | revocation_endpoint: string; 18 | introspection_endpoint: string; 19 | 20 | constructor(baseUrl: string) { 21 | this.issuer = baseUrl; 22 | this.authorization_endpoint = new URL('/oauth2/authorize', baseUrl).toString(); 23 | this.token_endpoint = new URL('/oauth2/token', baseUrl).toString(); 24 | this.introspection_endpoint = new URL('/oauth2/introspect', baseUrl).toString(); 25 | this.revocation_endpoint = new URL('/oauth2/revoke', baseUrl).toString(); 26 | 27 | this.registration_endpoint = new URL('/clients', baseUrl).toString(); 28 | this.jwks_uri = new URL('/.well-known/jwks.json', baseUrl).toString(); 29 | this.userinfo_endpoint = new URL('/userinfo', baseUrl).toString(); 30 | } 31 | 32 | toJson() { 33 | return JSON.stringify(this); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/open-id.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { OpenIdService } from './services'; 4 | import { UserInfoController, WellKnownController } from './controllers'; 5 | import { JwtModule } from '@app/lib/jwt'; 6 | import { AuthModule } from '@app/modules/auth'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule, 11 | AuthModule, 12 | JwtModule, 13 | ], 14 | providers: [ 15 | OpenIdService, 16 | ], 17 | controllers: [ 18 | WellKnownController, 19 | UserInfoController, 20 | ], 21 | }) 22 | export class OpenIdModule {} 23 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './open-id.service'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/openid/services/open-id.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { OpenIdConfig } from '../open-id-config'; 4 | 5 | @Injectable() 6 | export class OpenIdService { 7 | constructor( 8 | private readonly config: ConfigService, 9 | ) {} 10 | 11 | getConfig() { 12 | const appUrl = this.config.get('app.appUrl'); 13 | const openIdConfig = new OpenIdConfig(appUrl); 14 | 15 | return openIdConfig; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user-api/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user-api.controller'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user-api/controllers/user-api.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; 2 | import { UserService } from '@app/modules/user'; 3 | import { AccessToken, JwtGuard, Scope, ScopeGuard } from '@app/modules/auth'; 4 | import { AccessTokenJwtPayload } from '@app/modules/oauth2/interfaces'; 5 | import { ApiScopes } from '@app/modules/oauth2/constants'; 6 | 7 | @UseGuards(JwtGuard, ScopeGuard) 8 | @Controller('api/users') 9 | export class UserApiController { 10 | constructor( 11 | private readonly userService: UserService, 12 | ) {} 13 | 14 | @Scope(ApiScopes.users_profile, ApiScopes.users_email) 15 | @Get() 16 | async getUsers( 17 | @Query('ids') ids: string[], 18 | @AccessToken() token: AccessTokenJwtPayload, 19 | ) { 20 | if (!ids) { 21 | throw new BadRequestException('"ids" query parameter is required'); 22 | } 23 | return this.userService.repository.findByIds(ids) 24 | .then(users => users.map(u => u.toApiProfile(token.scopes as any))) 25 | .catch(err => { 26 | throw new BadRequestException(err.message); 27 | }) 28 | } 29 | 30 | @Scope(ApiScopes.users_profile, ApiScopes.users_email) 31 | @Get(':id') 32 | async getUser( 33 | @Param('id') id: string, 34 | @AccessToken() token: AccessTokenJwtPayload, 35 | ) { 36 | try { 37 | const user = await this.userService.repository.findOneOrFail(id); 38 | return user.toApiProfile(token.scopes as any); 39 | } catch (e) { 40 | return null; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user-api/user-api.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { AuthModule } from '@app/modules/auth'; 4 | import { UserModule } from '@app/modules/user'; 5 | import { UserApiController } from '@app/modules/user-api/controllers'; 6 | 7 | @Module({ 8 | imports: [ 9 | ConfigModule, 10 | AuthModule, 11 | UserModule, 12 | ], 13 | controllers: [ 14 | UserApiController, 15 | ], 16 | }) 17 | export class UserApiModule {} 18 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user/controllers/email.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, Redirect, UseFilters, UseGuards } from '@nestjs/common'; 2 | import { SignedGuard } from '@app/lib/sign'; 3 | import { AuthenticatedGuard } from '@app/modules/auth/guards'; 4 | import { ForbiddenExceptionFilter } from '@app/modules/auth/filters'; 5 | import { RegisterService } from '../services'; 6 | import { CurrentUser } from '@app/modules/auth/decorators'; 7 | import { User } from '@app/entities'; 8 | 9 | @UseFilters(ForbiddenExceptionFilter) 10 | @Controller('email') 11 | export class EmailController { 12 | constructor( 13 | private readonly registerService: RegisterService, 14 | ) {} 15 | 16 | @UseGuards(SignedGuard, AuthenticatedGuard) 17 | @Redirect('/') 18 | @Get('confirm/:idHash/:emailHash') 19 | async verifyEmail( 20 | @CurrentUser() user: User, 21 | @Param('idHash') idHash: string, 22 | @Param('emailHash') emailHash: string, 23 | ) { 24 | await this.registerService.verifyEmail(user, idHash, emailHash); 25 | return null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './email.controller'; 2 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.module'; 2 | export * from './services'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.service'; 2 | export * from './register.service'; 3 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user/services/password.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import estimator from 'zxcvbn'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | @Injectable() 6 | export class PasswordService { 7 | constructor( 8 | private readonly config: ConfigService, 9 | ) {} 10 | 11 | checkPasswordStrength(pass: string) { 12 | const result = estimator(pass); 13 | if (result.score < this.config.get('app.security.minPasswordScore')) { 14 | return result.feedback.warning || result.feedback.suggestions[0]; 15 | } 16 | return null; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user/test.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from '@app/entities'; 4 | import { Repository } from 'typeorm'; 5 | import { RegisterService } from './services'; 6 | 7 | @Controller('test') 8 | export class TestController { 9 | constructor( 10 | @InjectRepository(User) 11 | private userRepo: Repository, 12 | private readonly registerService: RegisterService, 13 | ) {} 14 | 15 | @Get() 16 | async test() { 17 | const user = await this.userRepo.findOne(); 18 | await this.registerService.sendWelcomeEmail(user); 19 | return 'ok'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/apps/oauth2/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { SocialLogin, User } from '@app/entities'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import { RegisterService, UserService } from '@app/modules/user/services'; 6 | import { MailModule } from '@app/modules/mail'; 7 | import { TestController } from '@app/modules/user/test.controller'; 8 | import { SignModule } from '@app/lib/sign'; 9 | import { EmailController } from '@app/modules/user/controllers'; 10 | import { CipherModule } from '@app/lib/cipher'; 11 | import { PasswordService } from '@app/modules/user/services/password.service'; 12 | 13 | @Module({ 14 | imports: [ 15 | ConfigModule, 16 | TypeOrmModule.forFeature([User, SocialLogin]), 17 | MailModule, 18 | SignModule.register({ 19 | secret: 'secret', 20 | ttl: 60 * 60 * 24, 21 | }), 22 | CipherModule.registerAsync({ 23 | imports: [ConfigModule], 24 | inject: [ConfigService], 25 | useFactory: (config: ConfigService) => config.get('crypto'), 26 | }), 27 | ], 28 | providers: [ 29 | UserService, 30 | RegisterService, 31 | PasswordService, 32 | ], 33 | controllers: [ 34 | TestController, 35 | EmailController, 36 | ], 37 | exports: [ 38 | UserService, 39 | RegisterService, 40 | ], 41 | }) 42 | export class UserModule {} 43 | -------------------------------------------------------------------------------- /src/apps/oauth2/request.d.ts: -------------------------------------------------------------------------------- 1 | // import { OAuthClient } from '@app/entities'; 2 | 3 | declare namespace Express { 4 | export interface Request { 5 | accessToken?: { 6 | sub: string; 7 | scopes: string[]; 8 | aud: string; 9 | iss: string; 10 | jti: string; 11 | }; 12 | client?: any; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/apps/oauth2/utils/confirm.validator.ts: -------------------------------------------------------------------------------- 1 | import { registerDecorator, ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; 2 | 3 | @ValidatorConstraint({name: 'confirm'}) 4 | export class ConfirmValidator implements ValidatorConstraintInterface { 5 | 6 | getFieldToMatch(args: ValidationArguments) { 7 | const thisProp = args.property; 8 | const name = args.constraints[0] || `${thisProp}Confirm`; 9 | const check = args.object[name]; 10 | return {name, check}; 11 | } 12 | 13 | validate(value: any, validationArguments?: ValidationArguments): boolean { 14 | const { check } = this.getFieldToMatch(validationArguments); 15 | 16 | return value === check; 17 | } 18 | 19 | defaultMessage(validationArguments?: ValidationArguments): string { 20 | const { name } = this.getFieldToMatch(validationArguments); 21 | return `${validationArguments.property} and ${name} do not match`; 22 | } 23 | } 24 | 25 | export function Confirm(property?: string, validationOptions?: ValidationOptions) { 26 | return function(object: object, propertyName: string) { // tslint:disable-line only-arrow-functions 27 | registerDecorator({ 28 | target: object.constructor, 29 | propertyName, 30 | options: validationOptions, 31 | constraints: [property], 32 | validator: ConfirmValidator, 33 | }); 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/apps/oauth2/utils/date.ts: -------------------------------------------------------------------------------- 1 | 2 | export function toEpochSeconds(date: Date | number): number { 3 | if (date instanceof Date) { 4 | return Math.floor(date.getTime() / 1000); 5 | } 6 | return Math.floor(date / 1000); 7 | } 8 | -------------------------------------------------------------------------------- /src/apps/oauth2/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './date'; 2 | export * from './url'; 3 | export * from './confirm.validator'; 4 | -------------------------------------------------------------------------------- /src/apps/oauth2/utils/url.ts: -------------------------------------------------------------------------------- 1 | 2 | export function url(str: string) { 3 | const urlRegex = '^(?!mailto:)(?:(?:http|https|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[0-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))|localhost)(?::\\d{2,5})?(?:(/|\\?|#)[^\\s]*)?$'; 4 | const url = new RegExp(urlRegex, 'i'); 5 | return str.length < 2083 && url.test(str); 6 | } 7 | -------------------------------------------------------------------------------- /src/config/app.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const app = registerAs('app', () => ({ 4 | appName: 'Argo', 5 | 6 | port: +process.env.PORT || 5000, 7 | appUrl: process.env.APP_URL, 8 | 9 | shouldSendWelcomeEmail: false, 10 | 11 | security: { 12 | minPasswordScore: 2, 13 | }, 14 | })) 15 | -------------------------------------------------------------------------------- /src/config/cert.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { resolve } from 'path'; 3 | 4 | const baseCertPath = resolve(__dirname, '../../certs'); 5 | 6 | export const cert = registerAs('cert', () => ({ 7 | baseCertPath, 8 | })); 9 | -------------------------------------------------------------------------------- /src/config/crypto.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { ConfigFactory } from '@nestjs/config/dist/interfaces'; 3 | import { CipherModuleOptions } from '@app/lib/cipher'; 4 | 5 | export const crypto = registerAs>('crypto', () => ({ 6 | iv: process.env.APP_IV || '1234567890123456', 7 | secret: process.env.APP_SECRET || '12345678901234567890123456789012', 8 | })); 9 | -------------------------------------------------------------------------------- /src/config/db.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { ConfigFactory } from '@nestjs/config/dist/interfaces'; 3 | import { ConnectionOptions } from 'typeorm'; 4 | 5 | // tslint:disable-next-line:no-var-requires 6 | const defaults = require('../../ormconfig'); 7 | 8 | export const db = registerAs>('db', () => ({ 9 | ...defaults, 10 | logging: process.env.NODE_LOG === 'debug', 11 | autoLoadEntities: true, 12 | entities: null, 13 | retryAttempts: 20, 14 | retryDelay: 5000, 15 | })); 16 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app'; 2 | export * from './db'; 3 | export * from './crypto'; 4 | export * from './jwt'; 5 | export * from './redis'; 6 | export * from './oauth'; 7 | export * from './cert'; 8 | export * from './mail'; 9 | export * from './management'; 10 | export * from './social'; 11 | export * from './rateLimit'; 12 | -------------------------------------------------------------------------------- /src/config/jwt.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { ConfigFactory } from '@nestjs/config/dist/interfaces'; 3 | 4 | export const jwt = registerAs>('jwt', () => ({ 5 | algorithm: 'RS256', 6 | })); 7 | -------------------------------------------------------------------------------- /src/config/mail.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { MailerOptions } from '@nestjs-modules/mailer'; 3 | import { ConfigFactory } from '@nestjs/config/dist/interfaces'; 4 | import { resolve } from 'path'; 5 | 6 | export const mail = registerAs('mail', () => ({ 7 | config: { 8 | transport: defaultTransport(), 9 | defaults: { 10 | from: { 11 | name: process.env.MAIL_FROM_NAME || 'Argo', 12 | address: process.env.MAIL_FROM || 'ingo@argo.com', 13 | }, 14 | }, 15 | template: { 16 | dir: resolve(__dirname, '..', '..', 'views/mail'), 17 | options: { 18 | strict: true, 19 | }, 20 | }, 21 | }, 22 | partials: [ 23 | resolve(__dirname, '..', '..', 'views/partials'), 24 | resolve(__dirname, '..', '..', 'views/layouts') 25 | ], 26 | queue: { 27 | prefix: process.env.NODE_UID || 'argo', 28 | defaultJobOptions: { 29 | removeOnComplete: 10, 30 | attempts: 3, 31 | }, 32 | }, 33 | })); 34 | 35 | function defaultTransport(): MailerOptions['transport'] { 36 | const transport: MailerOptions['transport'] = { 37 | host: process.env.MAIL_HOST, 38 | port: parseInt(process.env.MAIL_PORT, 10), 39 | secure: false, 40 | ignoreTLS: process.env.MAIL_SKIP_TLS === 'true', 41 | }; 42 | if (process.env.MAIL_USERNAME) { 43 | transport.auth = { 44 | user: process.env.MAIL_USERNAME, 45 | pass: process.env.MAIL_PASSWORD || null, 46 | }; 47 | } 48 | return transport; 49 | } 50 | -------------------------------------------------------------------------------- /src/config/management.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const management = registerAs('management', () => ({ 4 | })); 5 | -------------------------------------------------------------------------------- /src/config/oauth.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const oauth = registerAs('oauth', () => ({ 4 | authCodeTTL: 3600, 5 | accessTokenTTL: 36000, 6 | refreshTokenTTL: 3600000, 7 | 8 | accessTokenType: 'jwt', 9 | })); 10 | -------------------------------------------------------------------------------- /src/config/rateLimit.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { ConfigFactory } from '@nestjs/config/dist/interfaces'; 3 | import { Options } from 'express-rate-limit'; 4 | 5 | export const rateLimit = registerAs>('rateLimit', () => ({ 6 | max: 0, 7 | })); 8 | -------------------------------------------------------------------------------- /src/config/redis.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { ConfigFactory } from '@nestjs/config/dist/interfaces'; 3 | import { RedisModuleOptions } from '@app/lib/redis'; 4 | 5 | export const redis = registerAs>('redis', () => ({ 6 | ioredis: { 7 | host: process.env.REDIS_HOST, 8 | port: +process.env.REDIS_PORT, 9 | }, 10 | })) 11 | -------------------------------------------------------------------------------- /src/config/social.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import querystring from 'querystring'; 3 | 4 | export const social = registerAs('social', () => ({ 5 | facebook: { 6 | id: process.env.FACEBOOK_ID, 7 | secret: process.env.FACEBOOK_SECRET, 8 | graphUrl: 'https://graph.facebook.com/v7.0/', 9 | loginUrl: (state: string, appUrl: string) => { 10 | const stringifiedParams = querystring.stringify({ 11 | client_id: process.env.FACEBOOK_ID, 12 | redirect_uri: `${appUrl}/auth/social/facebook`, 13 | scope: ['email', 'public_profile'].join(','), 14 | response_type: 'code', 15 | state, 16 | }); 17 | return `https://www.facebook.com/v7.0/dialog/oauth?${stringifiedParams}`; 18 | }, 19 | }, 20 | google: { 21 | id: process.env.GOOGLE_ID, 22 | secret: process.env.GOOGLE_SECRET, 23 | tokenUrl: 'https://oauth2.googleapis.com/token', 24 | loginUrl: (state: string, appUrl: string) => { 25 | const stringifiedParams = querystring.stringify({ 26 | client_id: process.env.GOOGLE_ID, 27 | redirect_uri: `${appUrl}/auth/social/google`, 28 | scope: ['email', 'profile', 'openid'].join(' '), 29 | response_type: 'code', 30 | state, 31 | }); 32 | return `https://accounts.google.com/o/oauth2/v2/auth?${stringifiedParams}`; 33 | }, 34 | } 35 | })); 36 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | require('dotenv').config(); 3 | 4 | import { Command } from 'commander'; 5 | import bootstrapApp from './apps/oauth2/main'; 6 | import bootstrapCli from './apps/cli/main'; 7 | import bootstrapInit from './apps/init/main'; 8 | 9 | async function bootstrap() { 10 | const program = new Command(process.env.NODE_UID); 11 | program.version(require('../package.json').version); 12 | 13 | program 14 | .command('cli') 15 | .description('CLI') 16 | .name('cli') 17 | .action(bootstrapCli); 18 | 19 | program 20 | .command('serve', { isDefault: true }) 21 | .description('Start OAuth2 server') 22 | .name('serve') 23 | .action(bootstrapApp); 24 | 25 | program 26 | .command('init') 27 | .description('Run the init script (migrate and seed the DB)') 28 | .name('init') 29 | .action(bootstrapInit); 30 | 31 | await program.parseAsync(process.argv); 32 | } 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "sourceMap": false, 6 | "allowJs": false 7 | }, 8 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "client"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "skipLibCheck": true, 13 | "paths": { 14 | "@app/*": ["src/apps/oauth2/*"], 15 | "@init/*": ["src/apps/init/*"], 16 | "@config": ["src/config"] 17 | }, 18 | "incremental": true, 19 | "esModuleInterop": true 20 | }, 21 | "exclude": ["node_modules", "dist", "client"] 22 | } 23 | -------------------------------------------------------------------------------- /views/mail/welcome.hbs: -------------------------------------------------------------------------------- 1 | {{#extend "mail"}} 2 | {{#content "body"}} 3 |

4 |

Welcome {{nickname}}

5 | 6 |

7 | Click the button below to activate your account 8 |

9 | 10 |

11 | {{link}} 12 |

13 | 14 | Activate me 15 |
16 | 17 | {{/content}} 18 | {{/extend}} 19 | -------------------------------------------------------------------------------- /views/partials/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davide-Gheri/nestjs-oauth2/3bc3eb9f2b20d3ded242d3ac4e48ef594689cb0c/views/partials/.gitkeep --------------------------------------------------------------------------------