From 1c4ef8441f5a788186c3f057acbd0534fc23643b Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sat, 7 Oct 2023 15:03:28 +0530 Subject: [PATCH 01/96] refactor: start from scratch --- .eslintignore | 1 - .eslintrc.json | 17 - .github/COMMIT_CONVENTION.md | 70 -- .github/ISSUE_TEMPLATE/bug_report.md | 29 - .github/ISSUE_TEMPLATE/feature_request.md | 28 - .github/PULL_REQUEST_TEMPLATE.md | 28 - .github/labels.json | 170 ++++ .github/workflows/checks.yml | 61 ++ .github/workflows/test.yml | 27 - .husky/commit-msg | 2 +- .prettierrc | 10 - CONTRIBUTING.md | 48 - LICENSE.md | 2 +- README.md | 59 +- adonis-typings/auth.ts | 888 ---------------- adonis-typings/container.ts | 15 - adonis-typings/context.ts | 15 - adonis-typings/events.ts | 27 - adonis-typings/index.ts | 14 - adonis-typings/tests.ts | 43 - bin/japaTypes.ts | 7 - bin/test.ts | 38 - config.json | 13 - example/index.ts | 65 -- example/models.ts | 7 - instructions.ts | 463 --------- package.json | 168 ++- providers/AuthProvider.ts | 85 -- src/Auth/index.ts | 174 ---- src/AuthManager/index.ts | 376 ------- src/Bindings/Tests.ts | 84 -- src/Clients/Oat/index.ts | 159 --- src/Clients/Session/index.ts | 87 -- src/Exceptions/AuthenticationException.ts | 159 --- src/Exceptions/InvalidCredentialsException.ts | 119 --- src/Guards/Base/index.ts | 168 --- src/Guards/BasicAuth/index.ts | 215 ---- src/Guards/Oat/index.ts | 449 -------- src/Guards/Session/index.ts | 413 -------- src/TokenProviders/Database/index.ts | 161 --- src/TokenProviders/Redis/index.ts | 186 ---- src/Tokens/OpaqueToken/index.ts | 63 -- src/Tokens/ProviderToken/index.ts | 33 - src/UserProviders/Database/User.ts | 72 -- src/UserProviders/Database/index.ts | 180 ---- src/UserProviders/Lucid/User.ts | 74 -- src/UserProviders/Lucid/index.ts | 183 ---- standalone.ts | 10 - templates/config/auth.txt | 34 - templates/config/partials/api-guard.txt | 22 - templates/config/partials/basic-guard.txt | 19 - .../partials/tokens-provider-database.txt | 19 - .../config/partials/tokens-provider-redis.txt | 22 - .../partials/user-provider-database.txt | 43 - .../config/partials/user-provider-lucid.txt | 45 - templates/config/partials/web-guard.txt | 17 - templates/contract/auth.txt | 55 - templates/contract/partials/api-guard.txt | 14 - templates/contract/partials/basic-guard.txt | 14 - .../partials/user-provider-database.txt | 16 - .../contract/partials/user-provider-lucid.txt | 16 - templates/contract/partials/web-guard.txt | 14 - templates/middleware/Auth.txt | 76 -- templates/middleware/SilentAuth.txt | 21 - templates/migrations/api_tokens.txt | 25 - templates/migrations/auth.txt | 24 - templates/model.txt | 30 - test-helpers/contracts.ts | 48 - test-helpers/index.ts | 524 ---------- test/auth-manager.spec.ts | 349 ------- test/auth-provider.spec.ts | 112 -- test/auth.spec.ts | 503 --------- test/clients/oat.spec.ts | 126 --- test/clients/session.spec.ts | 75 -- test/guards/basic-auth.spec.ts | 339 ------- test/guards/oat.spec.ts | 957 ------------------ test/guards/session.spec.ts | 613 ----------- test/token-providers/database.spec.ts | 215 ---- test/token-providers/redis.spec.ts | 239 ----- test/user-providers/database.spec.ts | 295 ------ test/user-providers/lucid.spec.ts | 316 ------ tsconfig.json | 18 +- 82 files changed, 324 insertions(+), 10696 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.json delete mode 100644 .github/COMMIT_CONVENTION.md delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/labels.json create mode 100644 .github/workflows/checks.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .prettierrc delete mode 100644 CONTRIBUTING.md delete mode 100644 adonis-typings/auth.ts delete mode 100644 adonis-typings/container.ts delete mode 100644 adonis-typings/context.ts delete mode 100644 adonis-typings/events.ts delete mode 100644 adonis-typings/index.ts delete mode 100644 adonis-typings/tests.ts delete mode 100644 bin/japaTypes.ts delete mode 100644 bin/test.ts delete mode 100644 config.json delete mode 100644 example/index.ts delete mode 100644 example/models.ts delete mode 100644 instructions.ts delete mode 100644 providers/AuthProvider.ts delete mode 100644 src/Auth/index.ts delete mode 100644 src/AuthManager/index.ts delete mode 100644 src/Bindings/Tests.ts delete mode 100644 src/Clients/Oat/index.ts delete mode 100644 src/Clients/Session/index.ts delete mode 100644 src/Exceptions/AuthenticationException.ts delete mode 100644 src/Exceptions/InvalidCredentialsException.ts delete mode 100644 src/Guards/Base/index.ts delete mode 100644 src/Guards/BasicAuth/index.ts delete mode 100644 src/Guards/Oat/index.ts delete mode 100644 src/Guards/Session/index.ts delete mode 100644 src/TokenProviders/Database/index.ts delete mode 100644 src/TokenProviders/Redis/index.ts delete mode 100644 src/Tokens/OpaqueToken/index.ts delete mode 100644 src/Tokens/ProviderToken/index.ts delete mode 100644 src/UserProviders/Database/User.ts delete mode 100644 src/UserProviders/Database/index.ts delete mode 100644 src/UserProviders/Lucid/User.ts delete mode 100644 src/UserProviders/Lucid/index.ts delete mode 100644 standalone.ts delete mode 100644 templates/config/auth.txt delete mode 100644 templates/config/partials/api-guard.txt delete mode 100644 templates/config/partials/basic-guard.txt delete mode 100644 templates/config/partials/tokens-provider-database.txt delete mode 100644 templates/config/partials/tokens-provider-redis.txt delete mode 100644 templates/config/partials/user-provider-database.txt delete mode 100644 templates/config/partials/user-provider-lucid.txt delete mode 100644 templates/config/partials/web-guard.txt delete mode 100644 templates/contract/auth.txt delete mode 100644 templates/contract/partials/api-guard.txt delete mode 100644 templates/contract/partials/basic-guard.txt delete mode 100644 templates/contract/partials/user-provider-database.txt delete mode 100644 templates/contract/partials/user-provider-lucid.txt delete mode 100644 templates/contract/partials/web-guard.txt delete mode 100644 templates/middleware/Auth.txt delete mode 100644 templates/middleware/SilentAuth.txt delete mode 100644 templates/migrations/api_tokens.txt delete mode 100644 templates/migrations/auth.txt delete mode 100644 templates/model.txt delete mode 100644 test-helpers/contracts.ts delete mode 100644 test-helpers/index.ts delete mode 100644 test/auth-manager.spec.ts delete mode 100644 test/auth-provider.spec.ts delete mode 100644 test/auth.spec.ts delete mode 100644 test/clients/oat.spec.ts delete mode 100644 test/clients/session.spec.ts delete mode 100644 test/guards/basic-auth.spec.ts delete mode 100644 test/guards/oat.spec.ts delete mode 100644 test/guards/session.spec.ts delete mode 100644 test/token-providers/database.spec.ts delete mode 100644 test/token-providers/redis.spec.ts delete mode 100644 test/user-providers/database.spec.ts delete mode 100644 test/user-providers/lucid.spec.ts diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 378eac2..0000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -build diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 463da34..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": [ - "plugin:adonis/typescriptPackage", - "prettier" - ], - "plugins": [ - "prettier" - ], - "rules": { - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ] - } -} diff --git a/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md deleted file mode 100644 index fc852af..0000000 --- a/.github/COMMIT_CONVENTION.md +++ /dev/null @@ -1,70 +0,0 @@ -## Git Commit Message Convention - -> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). - -Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. - -``` js -/^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ -``` - -## Commit Message Format -A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: - -> The **scope** is optional - -``` -feat(router): add support for prefix - -Prefix makes it easier to append a path to a group of routes -``` - -1. `feat` is type. -2. `router` is scope and is optional -3. `add support for prefix` is the subject -4. The **body** is followed by a blank line. -5. The optional **footer** can be added after the body, followed by a blank line. - -## Types -Only one type can be used at a time and only following types are allowed. - -- feat -- fix -- docs -- style -- refactor -- perf -- test -- workflow -- ci -- chore -- types -- build - -If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. - -### Revert -If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. - -## Scope -The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. - -## Subject -The subject contains succinct description of the change: - -- use the imperative, present tense: "change" not "changed" nor "changes". -- don't capitalize first letter -- no dot (.) at the end - -## Body - -Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". -The body should include the motivation for the change and contrast this with previous behavior. - -## Footer - -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. - -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. - diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4bcd407..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Bug report -about: Report identified bugs ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -- Lots of raised issues are directly not bugs but instead are design decisions taken by us. -- Make use of our [forum](https://forum.adonisjs.com/), or [discord server](https://discord.me/adonisjs), if you are not sure that you are reporting a bug. -- Ensure the issue isn't already reported. -- Ensure you are reporting the bug in the correct repo. - -*Delete the above section and the instructions in the sections below before submitting* - -## Package version - - -## Node.js and npm version - - -## Sample Code (to reproduce the issue) - - -## BONUS (a sample repo to reproduce the issue) - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index abd44a5..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Feature request -about: Propose changes for adding a new feature ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -## Consider an RFC - -Please create an [RFC](https://github.com/adonisjs/rfcs) instead, if - -- Feature introduces a breaking change -- Demands lots of time and changes in the current code base. - -*Delete the above section and the instructions in the sections below before submitting* - -## Why this feature is required (specific use-cases will be appreciated)? - - -## Have you tried any other work arounds? - - -## Are you willing to work on it with little guidance? - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 7ddbcc5..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ - - -## Proposed changes - -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. - -## Types of changes - -What types of changes does your code introduce? - -_Put an `x` in the boxes that apply_ - -- [ ] Bugfix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - -## Checklist - -_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ - -- [ ] I have read the [CONTRIBUTING](https://github.com/adonisjs/auth/blob/master/CONTRIBUTING.md) doc -- [ ] Lint and unit tests pass locally with my changes -- [ ] I have added tests that prove my fix is effective or that my feature works. -- [ ] I have added necessary documentation (if appropriate) - -## Further comments - -If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..ba001c6 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,170 @@ +[ + { + "name": "Priority: Critical", + "color": "ea0056", + "description": "The issue needs urgent attention", + "aliases": [] + }, + { + "name": "Priority: High", + "color": "5666ed", + "description": "Look into this issue before picking up any new work", + "aliases": [] + }, + { + "name": "Priority: Medium", + "color": "f4ff61", + "description": "Try to fix the issue for the next patch/minor release", + "aliases": [] + }, + { + "name": "Priority: Low", + "color": "87dfd6", + "description": "Something worth considering, but not a top priority for the team", + "aliases": [] + }, + { + "name": "Semver: Alpha", + "color": "008480", + "description": "Will make it's way to the next alpha version of the package", + "aliases": [] + }, + { + "name": "Semver: Major", + "color": "ea0056", + "description": "Has breaking changes", + "aliases": [] + }, + { + "name": "Semver: Minor", + "color": "fbe555", + "description": "Mainly new features and improvements", + "aliases": [] + }, + { + "name": "Semver: Next", + "color": "5666ed", + "description": "Will make it's way to the bleeding edge version of the package", + "aliases": [] + }, + { + "name": "Semver: Patch", + "color": "87dfd6", + "description": "A bug fix", + "aliases": [] + }, + { + "name": "Status: Abandoned", + "color": "ffffff", + "description": "Dropped and not into consideration", + "aliases": ["wontfix"] + }, + { + "name": "Status: Accepted", + "color": "e5fbf2", + "description": "The proposal or the feature has been accepted for the future versions", + "aliases": [] + }, + { + "name": "Status: Blocked", + "color": "ea0056", + "description": "The work on the issue or the PR is blocked. Check comments for reasoning", + "aliases": [] + }, + { + "name": "Status: Completed", + "color": "008672", + "description": "The work has been completed, but not released yet", + "aliases": [] + }, + { + "name": "Status: In Progress", + "color": "73dbc4", + "description": "Still banging the keyboard", + "aliases": ["in progress"] + }, + { + "name": "Status: On Hold", + "color": "f4ff61", + "description": "The work was started earlier, but is on hold now. Check comments for reasoning", + "aliases": ["On Hold"] + }, + { + "name": "Status: Review Needed", + "color": "fbe555", + "description": "Review from the core team is required before moving forward", + "aliases": [] + }, + { + "name": "Status: Awaiting More Information", + "color": "89f8ce", + "description": "Waiting on the issue reporter or PR author to provide more information", + "aliases": [] + }, + { + "name": "Status: Need Contributors", + "color": "7057ff", + "description": "Looking for contributors to help us move forward with this issue or PR", + "aliases": [] + }, + { + "name": "Type: Bug", + "color": "ea0056", + "description": "The issue has indentified a bug", + "aliases": ["bug"] + }, + { + "name": "Type: Security", + "color": "ea0056", + "description": "Spotted security vulnerability and is a top priority for the core team", + "aliases": [] + }, + { + "name": "Type: Duplicate", + "color": "00837e", + "description": "Already answered or fixed previously", + "aliases": ["duplicate"] + }, + { + "name": "Type: Enhancement", + "color": "89f8ce", + "description": "Improving an existing feature", + "aliases": ["enhancement"] + }, + { + "name": "Type: Feature Request", + "color": "483add", + "description": "Request to add a new feature to the package", + "aliases": [] + }, + { + "name": "Type: Invalid", + "color": "dbdbdb", + "description": "Doesn't really belong here. Maybe use discussion threads?", + "aliases": ["invalid"] + }, + { + "name": "Type: Question", + "color": "eceafc", + "description": "Needs clarification", + "aliases": ["help wanted", "question"] + }, + { + "name": "Type: Documentation Change", + "color": "7057ff", + "description": "Documentation needs some improvements", + "aliases": ["documentation"] + }, + { + "name": "Type: Dependencies Update", + "color": "00837e", + "description": "Bump dependencies", + "aliases": ["dependencies"] + }, + { + "name": "Good First Issue", + "color": "008480", + "description": "Want to contribute? Just filter by this label", + "aliases": ["good first issue"] + } +] diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..98b0520 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,61 @@ +name: test +on: + - push + - pull_request +jobs: + lint: + uses: adonisjs/.github/.github/workflows/lint.yml@main + + typecheck: + uses: adonisjs/.github/.github/workflows/typecheck.yml@main + + test_linux: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.16.0, 20.x] + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests + run: npm test + env: + REDIS_HOST: 0.0.0.0 + REDIS_PORT: 6379 + + test_windows: + runs-on: windows-latest + strategy: + matrix: + node-version: [18.16.0, 20.x] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests + run: npm test + env: + NO_REDIS: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index e371165..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: test -on: - - push - - pull_request -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: - - 14.15.4 - - 16.x - redis-version: [5] - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Start Redis v${{ matrix.redis-version }} - uses: superchargejs/redis-github-action@1.1.0 - with: - redis-version: ${{ matrix.redis-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test diff --git a/.husky/commit-msg b/.husky/commit-msg index 4654c12..f7f3e30 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,3 +1,3 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js +npx --no -- commitlint --edit diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 07634f7..0000000 --- a/.prettierrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "trailingComma": "es5", - "semi": false, - "singleQuote": true, - "useTabs": false, - "quoteProps": "consistent", - "bracketSpacing": true, - "arrowParens": "always", - "printWidth": 100 -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 992acdb..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,48 +0,0 @@ -# Contributing - -AdonisJs is a community driven project. You are free to contribute in any of the following ways. - -- [Coding style](coding-style) -- [Fix bugs by creating PR's](fix-bugs-by-creating-prs) -- [Share an RFC for new features or big changes](share-an-rfc-for-new-features-or-big-changes) -- [Report security issues](report-security-issues) -- [Be a part of the community](be-a-part-of-community) - -## Coding style - -Majority of AdonisJs core packages are written in Typescript. Having a brief knowledge of Typescript is required to contribute to the core. [Learn more](https://adonisjs.com/coding-style) about the same. - -## Fix bugs by creating PR's - -We appreciate every time you report a bug in the framework or related libraries. However, taking time to submit a PR can help us in fixing bugs quickly and ensure a healthy and stable eco-system. - -Go through the following points, before creating a new PR. - -1. Create an issue discussing the bug or short-coming in the framework. -2. Once approved, go ahead and fork the REPO. -3. Make sure to start from the `develop`, since this is the upto date branch. -4. Make sure to keep commits small and relevant. -5. We follow [conventional-commits](https://github.com/conventional-changelog/conventional-changelog) to structure our commit messages. Instead of running `git commit`, you must run `npm commit`, which will show you prompts to create a valid commit message. -6. Once done with all the changes, create a PR against the `develop` branch. - -## Share an RFC for new features or big changes - -Sharing PR's for small changes works great. However, when contributing big features to the framework, it is required to go through the RFC process. - -### What is an RFC? - -RFC stands for **Request for Commits**, a standard process followed by many other frameworks including [Ember](https://github.com/emberjs/rfcs), [yarn](https://github.com/yarnpkg/rfcs) and [rust](https://github.com/rust-lang/rfcs). - -In brief, RFC process allows you to talk about the changes with everyone in the community and get a view of the core team before dedicating your time to work on the feature. - -The RFC proposals are created as issues on [adonisjs/rfcs](https://github.com/adonisjs/rfcs) repo. Make sure to read the README to learn about the process in depth. - -## Report security issues - -All of the security issues, must be reported via [email](mailto:virk@adonisjs.com) and not using any of the public channels. [Learn more](https://adonisjs.com/security) about the security policy - -## Be a part of community - -We welcome you to participate in the [forum](https://forum.adonisjs.com/) and the AdonisJs [discord server](https://discord.me/adonisjs). You are free to ask your questions and share your work or contributions made to AdonisJs eco-system. - -We follow a strict [Code of Conduct](https://adonisjs.com/community-guidelines) to make sure everyone is respectful to each other. diff --git a/LICENSE.md b/LICENSE.md index 47ca6df..381426b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # The MIT License -Copyright 2021 Harminder Virk, contributors +Copyright (c) 2023 Harminder Virk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index e947b3b..763d8d4 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,32 @@ -
- -
+# @adonisjs/auth
-
-

Adonis Auth

-

The official user authentication package for AdonisJS. Ships with sessions, api tokens and basic auth guards.

-
+[![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] -
- -
+## Introduction -[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] [![synk-image]][synk-url] +## Official Documentation +The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/auth/introduction) -
+## Contributing +One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. -
-

- - Website - - | - - Guides - - | - - Contributing - -

-
+We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. -
- Built with ❤︎ by Harminder Virk -
+## Code of Conduct +In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). -[gh-workflow-image]: https://img.shields.io/github/workflow/status/adonisjs/auth/test?style=for-the-badge -[gh-workflow-url]: https://github.com/adonisjs/auth/actions/workflows/test.yml "Github action" +## License +AdonisJS auth is open-sourced software licensed under the [MIT license](LICENSE.md). -[typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript -[typescript-url]: "typescript" +[gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/auth/checks.yml?style=for-the-badge +[gh-workflow-url]: https://github.com/adonisjs/auth/actions/workflows/checks.yml "Github action" -[npm-image]: https://img.shields.io/npm/v/@adonisjs/auth/alpha.svg?style=for-the-badge&logo=npm -[npm-url]: https://www.npmjs.com/package/@adonisjs/auth/v/alpha "npm" +[npm-image]: https://img.shields.io/npm/v/@adonisjs/auth/latest.svg?style=for-the-badge&logo=npm +[npm-url]: https://www.npmjs.com/package/@adonisjs/auth/v/latest "npm" -[license-image]: https://img.shields.io/npm/l/@adonisjs/auth?color=blueviolet&style=for-the-badge -[license-url]: LICENSE.md "license" +[typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript -[synk-image]: https://img.shields.io/snyk/vulnerabilities/github/adonisjs/auth?label=Synk%20Vulnerabilities&style=for-the-badge -[synk-url]: https://snyk.io/test/github/adonisjs/auth?targetFile=package.json "synk" +[license-url]: LICENSE.md +[license-image]: https://img.shields.io/github/license/adonisjs/auth?style=for-the-badge diff --git a/adonis-typings/auth.ts b/adonis-typings/auth.ts deleted file mode 100644 index 6258f92..0000000 --- a/adonis-typings/auth.ts +++ /dev/null @@ -1,888 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Addons/Auth' { - import { DateTime } from 'luxon' - import { HashersList } from '@ioc:Adonis/Core/Hash' - import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' - import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - import { ApplicationContract } from '@ioc:Adonis/Core/Application' - import { DatabaseQueryBuilderContract } from '@ioc:Adonis/Lucid/Database' - import { LucidModel, LucidRow, ModelQueryBuilderContract } from '@ioc:Adonis/Lucid/Orm' - - /* - |-------------------------------------------------------------------------- - | Helpers - |-------------------------------------------------------------------------- - */ - - /** - * Unwraps user from the provider user - */ - type UnWrapProviderUser = T extends ProviderUserContract ? Exclude : T - - /** - * Unwraps awaited type from Promise - */ - type Awaited = T extends PromiseLike ? Awaited : T - - /** - * Returns the real user from the provider user - */ - export type GetProviderRealUser = UnWrapProviderUser< - Awaited> - > - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - */ - - /** - * Provider user works as a bridge between the provider real user - * and the guard. It is never exposed to the end-user. - */ - export interface ProviderUserContract { - user: User | null - getId(): string | number | null - verifyPassword: (plainPassword: string) => Promise - getRememberMeToken(): string | null - setRememberMeToken(token: string): void - } - - /** - * The interface that every provider must implement - */ - export interface UserProviderContract { - /** - * Return an instance of the user wrapped inside the Provider user contract - */ - getUserFor(user: User): Promise> - - /** - * Find a user using the primary key value - */ - findById(id: string | number): Promise> - - /** - * Find a user by searching for their uids - */ - findByUid(uid: string): Promise> - - /** - * Find a user using the remember me token - */ - findByRememberMeToken( - userId: string | number, - token: string - ): Promise> - - /** - * Update remember token - */ - updateRememberMeToken(authenticatable: ProviderUserContract): Promise - } - - /* - |-------------------------------------------------------------------------- - | Token Providers - |-------------------------------------------------------------------------- - */ - - /** - * Shape of the token sent to/read from the tokens provider - */ - export interface ProviderTokenContract { - /** - * Persisted token value. It is a sha256 hash - */ - tokenHash: string - - /** - * Token name - */ - name: string - - /** - * Token type - */ - type: string - - /** - * UserId for which the token was saved - */ - userId: string | number - - /** - * Expiry date - */ - expiresAt?: DateTime - - /** - * All other token details - */ - meta?: any - } - - /** - * Token providers provides the API to create/fetch and delete tokens - * for a given user. Any token based implementation can use token - * providers, given they only store a single token. - */ - export interface TokenProviderContract { - /** - * Define a custom connection for the driver in use - */ - setConnection(connection: any): this - - /** - * Saves the token to some persistance storage and returns an lookup - * id. We introduced the concept of lookup ids, since lookups by - * cryptographic tokens can have performance impacts on certain - * databases. - * - * Also note that the return lookup id is also prepended to the raw - * token, so that we can later extract the id for reads. The - * real message is to keep the lookup ids small. - */ - write(token: ProviderTokenContract): Promise - - /** - * Find token using the lookup id or the token value - */ - read(lookupId: string, token: string, type: string): Promise - - /** - * Delete token using the lookup id or the token value - */ - destroy(lookupId: string, type: string): Promise - } - - /** - * Config for the database token provider - */ - export type DatabaseTokenProviderConfig = { - driver: 'database' - table: string - foreignKey?: string - connection?: string - type?: string - } - - /** - * Config for the redis token provider - */ - export type RedisTokenProviderConfig = { - driver: 'redis' - redisConnection: string - foreignKey?: string - type?: string - } - - /* - |-------------------------------------------------------------------------- - | Lucid Provider - |-------------------------------------------------------------------------- - */ - - /** - * The shape of the user model accepted by the Lucid provider. The model - * must have `password` and `rememberMeToken` attributes. - */ - export type LucidProviderModel = LucidModel & { - findForAuth?: ( - this: T, - uids: string[], - value: any - ) => Promise> - } & { - new (): LucidRow & { - password?: string | null - rememberMeToken?: string | null - } - } - - /** - * Shape of the lucid provider user builder. It must return [[ProviderUserContract]] - */ - export interface LucidProviderUserBuilder { - new ( - user: InstanceType | null, - config: LucidProviderConfig, - ...args: any[] - ): ProviderUserContract> - } - - /** - * Lucid provider - */ - export interface LucidProviderContract - extends UserProviderContract> { - /** - * Define a custom connection for all the provider queries - */ - setConnection(connection: string | QueryClientContract): this - - /** - * Before hooks - */ - before( - event: 'findUser', - callback: (query: ModelQueryBuilderContract) => Promise - ): this - - /** - * After hooks - */ - after(event: 'findUser', callback: (user: InstanceType) => Promise): this - } - - /** - * The config accepted by the Lucid provider - */ - export type LucidProviderConfig = { - driver: 'lucid' - model: () => Promise | Promise<{ default: User }> - uids: (keyof InstanceType)[] - identifierKey: string - connection?: string - hashDriver?: keyof HashersList - user?: () => - | Promise> - | Promise<{ default: LucidProviderUserBuilder }> - } - - /* - |-------------------------------------------------------------------------- - | Database Provider - |-------------------------------------------------------------------------- - */ - - /** - * Shape of the row returned by the database provider. The table must have `password` - * and `remember_me_token` columns. - */ - export type DatabaseProviderRow = { - password?: string - remember_me_token?: string - [key: string]: any - } - - /** - * Shape of database provider user builder. It must always returns [[ProviderUserContract]] - */ - export interface DatabaseProviderUserBuilder { - new ( - user: DatabaseProviderRow | null, - config: DatabaseProviderConfig, - ...args: any[] - ): ProviderUserContract - } - - /** - * Database provider - */ - export interface DatabaseProviderContract - extends UserProviderContract { - /** - * Define a custom connection for all the provider queries - */ - setConnection(connection: string | QueryClientContract): this - - /** - * Before hooks - */ - before( - event: 'findUser', - callback: (query: DatabaseQueryBuilderContract) => Promise - ): this - - /** - * After hooks - */ - after(event: 'findUser', callback: (user: DatabaseProviderRow) => Promise): this - } - - /** - * The config accepted by the Database provider - */ - export type DatabaseProviderConfig = { - driver: 'database' - uids: string[] - usersTable: string - identifierKey: string - connection?: string - hashDriver?: keyof HashersList - user?: () => - | Promise - | Promise<{ default: DatabaseProviderUserBuilder }> - } - - /** - * Request data a guard client can set when making the - * testing request - */ - export type ClientRequestData = { - session?: Record - headers?: Record - cookies?: Record - } - - /** - * The authentication clients should follow this interface - */ - export interface GuardClientContract { - /** - * Login a user - */ - login(user: GetProviderRealUser, ...args: any[]): Promise - - /** - * Logout user - */ - logout(user: GetProviderRealUser): Promise - } - - /* - |-------------------------------------------------------------------------- - | Guards - |-------------------------------------------------------------------------- - */ - export interface GuardContract< - Provider extends keyof ProvidersList, - Guard extends keyof GuardsList - > { - name: Guard - - /** - * Reference to the guard config - */ - config: GuardsList[Guard]['config'] - - /** - * Reference to the logged in user. - */ - user?: GetProviderRealUser - - /** - * Find if the user has been logged out in the current request - */ - isLoggedOut: boolean - - /** - * A boolean to know if user is a guest or not. It is - * always opposite of [[isLoggedIn]] - */ - isGuest: boolean - - /** - * A boolean to know if user is logged in or not - */ - isLoggedIn: boolean - - /** - * A boolean to know if user is retrieved by authenticating - * the current request or not. - */ - isAuthenticated: boolean - - /** - * Whether or not the authentication has been attempted - * for the current request - */ - authenticationAttempted: boolean - - /** - * Reference to the provider for looking up the user - */ - provider: ProvidersList[Provider]['implementation'] - - /** - * Verify user credentials. - */ - verifyCredentials(uid: string, password: string): Promise> - - /** - * Attempt to verify user credentials and perform login - */ - attempt(uid: string, password: string, ...args: any[]): Promise - - /** - * Login a user without any verification - */ - login(user: GetProviderRealUser, ...args: any[]): Promise - - /** - * Login a user using their id - */ - loginViaId(id: string | number, ...args: any[]): Promise - - /** - * Attempts to authenticate the user for the current HTTP request. An exception - * is raised when unable to do so - */ - authenticate(): Promise> - - /** - * Attempts to authenticate the user for the current HTTP request and supresses - * exceptions raised by the [[authenticate]] method and returns a boolean - */ - check(): Promise - - /** - * Logout user - */ - logout(...args: any[]): Promise - - /** - * Serialize guard to JSON - */ - toJSON(): any - } - - /* - |-------------------------------------------------------------------------- - | Session Guard - |-------------------------------------------------------------------------- - */ - - /** - * Shape of data emitted by the login event - */ - export type SessionLoginEventData = { - name: string - user: GetProviderRealUser - ctx: HttpContextContract - token: string | null - } - - /** - * Shape of data emitted by the authenticate event - */ - export type SessionAuthenticateEventData = { - name: string - user: GetProviderRealUser - ctx: HttpContextContract - viaRemember: boolean - } - - /** - * Shape of the session guard - */ - export interface SessionGuardContract< - Provider extends keyof ProvidersList, - Name extends keyof GuardsList - > extends GuardContract { - /** - * A boolean to know if user is loggedin via remember me token or not. - */ - viaRemember: boolean - - /** - * Attempt to verify user credentials and perform login - */ - attempt(uid: string, password: string, remember?: boolean): Promise - - /** - * Login a user without any verification - */ - login(user: GetProviderRealUser, remember?: boolean): Promise - - /** - * Login a user using their id - */ - loginViaId(id: string | number, remember?: boolean): Promise - - /** - * Logout user - */ - logout(renewRememberToken?: boolean): Promise - } - - /** - * Session client to login users during tests - */ - export interface SessionClientContract - extends GuardClientContract {} - - /** - * Shape of session driver config. - */ - export type SessionGuardConfig = { - driver: 'session' - provider: ProvidersList[Provider]['config'] - } - - /* - |-------------------------------------------------------------------------- - | Basic Auth Guard - |-------------------------------------------------------------------------- - */ - - /** - * Shape of data emitted by the authenticate event - */ - export type BasicAuthAuthenticateEventData = { - name: string - user: GetProviderRealUser - ctx: HttpContextContract - } - - /** - * Shape of the basic auth guard - */ - export interface BasicAuthGuardContract< - Provider extends keyof ProvidersList, - Name extends keyof GuardsList - > extends Omit, 'attempt' | 'login' | 'loginViaId' | 'logout'> {} - - /** - * Basic auth client to login users during tests - */ - export interface BasicAuthClientContract - extends GuardClientContract {} - - /** - * Shape of basic auth guard config. - */ - export type BasicAuthGuardConfig = { - driver: 'basic' - realm?: string - provider: ProvidersList[Provider]['config'] - } - - /* - |-------------------------------------------------------------------------- - | OAT Token Guard - |-------------------------------------------------------------------------- - | - | OAT stands for `Opaque Access Token`. The abbrevation is not a standard, - | however, the "Opaque Access Token" is a widely accepted term. - */ - - /** - * Opaque token is generated during the login call by the OpaqueTokensGuard - */ - export interface OpaqueTokenContract { - /** - * Always a bearer token - */ - type: 'bearer' - - /** - * The user for which the token was generated - */ - user: User - - /** - * Date/time when the token will be expired - */ - expiresAt?: DateTime - - /** - * Time in seconds until the token is valid - */ - expiresIn?: number - - /** - * Any meta-data attached with the token - */ - meta: any - - /** - * Token name - */ - name: string - - /** - * Token public value - */ - token: string - - /** - * Token hash (persisted to the db as well) - */ - tokenHash: string - - /** - * Serialize token - */ - toJSON(): { - type: 'bearer' - token: string - expires_at?: string - expires_in?: number - } - } - - /** - * Login options - */ - export type OATLoginOptions = { - name?: string - expiresIn?: number | string - } & { [key: string]: any } - - /** - * Shape of data emitted by the login event - */ - export type OATLoginEventData = { - name: string - user: GetProviderRealUser - ctx: HttpContextContract - token: OpaqueTokenContract> - } - - /** - * Shape of the data emitted by the authenticate event - */ - export type OATAuthenticateEventData = { - name: string - user: GetProviderRealUser - ctx: HttpContextContract - token: ProviderTokenContract - } - - /** - * Shape of the OAT guard - */ - export interface OATGuardContract< - Provider extends keyof ProvidersList, - Name extends keyof GuardsList - > extends GuardContract { - token?: ProviderTokenContract - tokenProvider: TokenProviderContract - - /** - * Attempt to verify user credentials and perform login - */ - attempt( - uid: string, - password: string, - options?: OATLoginOptions - ): Promise>> - - /** - * Login a user without any verification - */ - login( - user: GetProviderRealUser, - options?: OATLoginOptions - ): Promise>> - - /** - * Generate token for a user without any verification - */ - generate( - user: GetProviderRealUser, - options?: OATLoginOptions - ): Promise>> - - /** - * Alias for logout - */ - revoke(): Promise - - /** - * Login a user using their id - */ - loginViaId( - id: string | number, - options?: OATLoginOptions - ): Promise>> - } - - /** - * Oat guard to login users during tests - */ - export interface OATClientContract - extends GuardClientContract { - login( - user: GetProviderRealUser, - options?: OATLoginOptions - ): Promise - } - - /** - * Shape of OAT guard config. - */ - export type OATGuardConfig = { - /** - * Driver name is always constant - */ - driver: 'oat' - - /** - * Provider for managing tokens - */ - tokenProvider: DatabaseTokenProviderConfig | RedisTokenProviderConfig - - /** - * User provider - */ - provider: ProvidersList[Provider]['config'] - } - - /* - |-------------------------------------------------------------------------- - | Auth User Land List - |-------------------------------------------------------------------------- - */ - - /** - * List of providers mappings used by the app. Using declaration - * merging, one must extend this interface. - * - * MUST BE SET IN THE USER LAND. - * - * Example: - * - * lucid: { - * config: LucidProviderConfig, - * implementation: LucidProviderContract, - * } - * - */ - export interface ProvidersList {} - - /** - * List of guards mappings used by the app. Using declaration - * merging, one must extend this interface. - * - * MUST BE SET IN THE USER LAND. - * - * Example: - * - * session: { - * config: SessionGuardConfig<'lucid'>, - * implementation: SessionGuardContract<'lucid'>, - * client: SessionClientContract<'lucid'>, - * } - * - */ - export interface GuardsList {} - - /* - |-------------------------------------------------------------------------- - | Auth - |-------------------------------------------------------------------------- - */ - - /** - * Shape of config accepted by the Auth module. It relies on the - * [[GuardsList]] interface - */ - export type AuthConfig = { - guard: keyof GuardsList - guards: { - [P in keyof GuardsList]: GuardsList[P]['config'] - } - } - - /** - * Instance of the auth contract. The `use` method can be used to obtain - * an instance of a given guard mapping for a single HTTP request - */ - export interface AuthContract extends GuardContract { - /** - * The default guard for the current request - */ - defaultGuard: string - - /** - * Use a given guard - */ - use(): GuardContract - use(guard: K): GuardsList[K]['implementation'] - } - - /* - |-------------------------------------------------------------------------- - | Auth Manager - |-------------------------------------------------------------------------- - */ - - /** - * Shape of the callback accepted to add new user providers - */ - export type ExtendProviderCallback = ( - auth: AuthManagerContract, - mapping: string, - config: any - ) => UserProviderContract - - /** - * Shape of the callback accepted to add new guards - */ - export type ExtendGuardCallback = ( - auth: AuthManagerContract, - mapping: string, - config: any, - provider: UserProviderContract, - ctx: HttpContextContract - ) => GuardContract - - /** - * Shape of the callback accepted to add custom testing - * clients - */ - export type ExtendClientCallback = ( - auth: AuthManagerContract, - mapping: string, - config: any, - provider: UserProviderContract - ) => GuardClientContract - - /** - * Shape of the auth manager to register custom drivers and providers and - * make instances of them - */ - export interface AuthManagerContract { - application: ApplicationContract - - /** - * The default guard - */ - defaultGuard: string - - /** - * Returns the instance of [[AuthContract]] for a given HTTP request - */ - getAuthForRequest(ctx: HttpContextContract): AuthContract - - /** - * Make instance of a mapping - */ - makeMapping( - ctx: HttpContextContract, - mapping: string - ): GuardContract - makeMapping( - ctx: HttpContextContract, - mapping: K - ): GuardsList[K]['implementation'] - - /** - * Returns an instance of the auth client for a given - * mapping - */ - client(mapping: string): GuardClientContract - - /** - * Extend by adding custom providers, guards and client - */ - extend(type: 'provider', provider: string, callback: ExtendProviderCallback): void - extend(type: 'guard', guard: string, callback: ExtendGuardCallback): void - extend(type: 'client', guard: string, callback: ExtendClientCallback): void - } - - const AuthManager: AuthManagerContract - export default AuthManager -} diff --git a/adonis-typings/container.ts b/adonis-typings/container.ts deleted file mode 100644 index 1defd8e..0000000 --- a/adonis-typings/container.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/Application' { - import { AuthManagerContract } from '@ioc:Adonis/Addons/Auth' - export interface ContainerBindings { - 'Adonis/Addons/Auth': AuthManagerContract - } -} diff --git a/adonis-typings/context.ts b/adonis-typings/context.ts deleted file mode 100644 index 28b3416..0000000 --- a/adonis-typings/context.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/HttpContext' { - import { AuthContract } from '@ioc:Adonis/Addons/Auth' - interface HttpContextContract { - auth: AuthContract - } -} diff --git a/adonis-typings/events.ts b/adonis-typings/events.ts deleted file mode 100644 index 75f9114..0000000 --- a/adonis-typings/events.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/Event' { - import { - ProvidersList, - OATLoginEventData, - SessionLoginEventData, - OATAuthenticateEventData, - SessionAuthenticateEventData, - BasicAuthAuthenticateEventData, - } from '@ioc:Adonis/Addons/Auth' - - export interface EventsList { - 'adonis:basic:authenticate': BasicAuthAuthenticateEventData - 'adonis:session:login': SessionLoginEventData - 'adonis:session:authenticate': SessionAuthenticateEventData - 'adonis:api:authenticate': OATAuthenticateEventData - 'adonis:api:login': OATLoginEventData - } -} diff --git a/adonis-typings/index.ts b/adonis-typings/index.ts deleted file mode 100644 index 6499378..0000000 --- a/adonis-typings/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// -/// -/// -/// -/// diff --git a/adonis-typings/tests.ts b/adonis-typings/tests.ts deleted file mode 100644 index cde7594..0000000 --- a/adonis-typings/tests.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import '@japa/api-client' -import { - GuardsList, - ProvidersList, - AuthManagerContract, - GetProviderRealUser, -} from '@ioc:Adonis/Addons/Auth' - -declare module '@japa/api-client' { - export interface ApiRequest { - /** - * Auth manager reference - */ - authManager: AuthManagerContract - - /** - * Switch guard to login during the request - */ - guard( - this: Self, - guard: K - ): { - /** - * Login as a user - */ - loginAs(...args: Parameters): Self - } - - /** - * Login as a user - */ - loginAs(user: GetProviderRealUser): this - } -} diff --git a/bin/japaTypes.ts b/bin/japaTypes.ts deleted file mode 100644 index d42cac6..0000000 --- a/bin/japaTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Assert } from '@japa/assert' - -declare module '@japa/runner' { - interface TestContext { - assert: Assert - } -} diff --git a/bin/test.ts b/bin/test.ts deleted file mode 100644 index ce1b844..0000000 --- a/bin/test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { assert } from '@japa/assert' -import { specReporter } from '@japa/spec-reporter' -import { runFailedTests } from '@japa/run-failed-tests' -import { processCliArgs, configure, run } from '@japa/runner' -import 'reflect-metadata' - -/* -|-------------------------------------------------------------------------- -| Configure tests -|-------------------------------------------------------------------------- -| -| The configure method accepts the configuration to configure the Japa -| tests runner. -| -| The first method call "processCliArgs" process the command line arguments -| and turns them into a config object. Using this method is not mandatory. -| -| Please consult japa.dev/runner-config for the config docs. -*/ -configure({ - ...processCliArgs(process.argv.slice(2)), - ...{ - files: ['test/**/*.spec.ts'], - plugins: [assert(), runFailedTests()], - reporters: [specReporter()], - importer: (filePath: string) => import(filePath), - }, -}) - -/* -|-------------------------------------------------------------------------- -| Run tests -|-------------------------------------------------------------------------- -| -| The following "run" method is required to execute all the tests. -| -*/ -run() diff --git a/config.json b/config.json deleted file mode 100644 index 1268949..0000000 --- a/config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "core": true, - "license": "MIT", - "services": [ - "github-actions" - ], - "minNodeVersion": "14.15.4", - "probotApps": [ - "stale", - "lock" - ], - "runGhActionsOnWindows": false -} diff --git a/example/index.ts b/example/index.ts deleted file mode 100644 index 3ce7c73..0000000 --- a/example/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { User } from './models' -import { AuthConfig, AuthContract } from '@ioc:Adonis/Addons/Auth' - -export const config: AuthConfig = { - guard: 'session', - guards: { - session: { - driver: 'session', - provider: { - driver: 'lucid', - model: async () => User, - identifierKey: 'id', - uids: ['email'], - }, - }, - basic: { - driver: 'basic', - provider: { - driver: 'lucid', - model: async () => User, - identifierKey: 'id', - uids: ['email'], - }, - }, - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: { - driver: 'lucid', - model: async () => User, - identifierKey: 'id', - uids: ['email'], - }, - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: { - driver: 'database', - usersTable: 'users', - identifierKey: 'id', - uids: ['email'], - }, - }, - sessionDb: { - driver: 'session', - provider: { - driver: 'database', - usersTable: 'users', - identifierKey: 'id', - uids: ['email'], - }, - }, - }, -} - -const a = {} as AuthContract -a.loginViaId(1) -// a.use('basic'). diff --git a/example/models.ts b/example/models.ts deleted file mode 100644 index bec01be..0000000 --- a/example/models.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseModel } from '@ioc:Adonis/Lucid/Orm' - -export class User extends BaseModel { - public password: string - public email: string - public rememberMeToken: string -} diff --git a/instructions.ts b/instructions.ts deleted file mode 100644 index 08c1535..0000000 --- a/instructions.ts +++ /dev/null @@ -1,463 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { join } from 'path' -import * as sinkStatic from '@adonisjs/sink' -import { string } from '@poppinss/utils/build/helpers' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -type InstructionsState = { - modelName?: string - modelReference?: string - modelNamespace?: string - - usersTableName: string - usersSchemaName: string - - tokensTableName: string - tokensSchemaName: string - - provider: 'lucid' | 'database' - tokensProvider: 'database' | 'redis' - guards: ('web' | 'api' | 'basic')[] - - hasGuard: { - web: boolean - api: boolean - basic: boolean - } -} - -// const USER_MIGRATION_TIME_PREFIX = '1587988332388' -// const TOKENS_MIGRATION_TIME_PREFIX = '1592489784670' - -/** - * Base path to contract stub partials - */ -const CONTRACTS_PARTIALS_BASE = './contract/partials' - -/** - * Base path to config stub partials - */ -const CONFIG_PARTIALS_BASE = './config/partials' - -/** - * Prompt choices for the provider selection - */ -const PROVIDER_PROMPT_CHOICES = [ - { - name: 'lucid' as const, - message: 'Lucid', - hint: ' (Uses Data Models)', - }, - { - name: 'database' as const, - message: 'Database', - hint: ' (Uses Database QueryBuilder)', - }, -] - -/** - * Prompt choices for the guard selection - */ -const GUARD_PROMPT_CHOICES = [ - { - name: 'web' as const, - message: 'Web', - hint: ' (Uses sessions for managing auth state)', - }, - { - name: 'api' as const, - message: 'API tokens', - hint: ' (Uses database backed opaque tokens)', - }, - { - name: 'basic' as const, - message: 'Basic Auth', - hint: ' (Uses HTTP Basic auth for authenticating requests)', - }, -] - -/** - * Prompt choices for the tokens provider selection - */ -const TOKENS_PROVIDER_PROMPT_CHOICES = [ - { - name: 'database' as const, - message: 'Database', - hint: ' (Uses SQL table for storing API tokens)', - }, - { - name: 'redis' as const, - message: 'Redis', - hint: ' (Uses Redis for storing API tokens)', - }, -] - -/** - * Returns absolute path to the stub relative from the templates - * directory - */ -function getStub(...relativePaths: string[]) { - return join(__dirname, 'templates', ...relativePaths) -} - -/** - * Creates the model file - */ -function makeModel( - projectRoot: string, - app: ApplicationContract, - sink: typeof sinkStatic, - state: InstructionsState -) { - const modelsDirectory = app.resolveNamespaceDirectory('models') || 'app/Models' - const modelPath = join(modelsDirectory, `${state.modelName}.ts`) - - const template = new sink.files.MustacheFile(projectRoot, modelPath, getStub('model.txt')) - if (template.exists()) { - sink.logger.action('create').skipped(`${modelPath} file already exists`) - return - } - - template.apply(state).commit() - sink.logger.action('create').succeeded(modelPath) -} - -/** - * Create the migration file - */ -function makeUsersMigration( - projectRoot: string, - app: ApplicationContract, - sink: typeof sinkStatic, - state: InstructionsState -) { - const migrationsDirectory = app.directoriesMap.get('migrations') || 'database' - const migrationPath = join(migrationsDirectory, `${Date.now()}_${state.usersTableName}.ts`) - - const template = new sink.files.MustacheFile( - projectRoot, - migrationPath, - getStub('migrations/auth.txt') - ) - if (template.exists()) { - sink.logger.action('create').skipped(`${migrationPath} file already exists`) - return - } - - template.apply(state).commit() - sink.logger.action('create').succeeded(migrationPath) -} - -/** - * Create the migration file - */ -function makeTokensMigration( - projectRoot: string, - app: ApplicationContract, - sink: typeof sinkStatic, - state: InstructionsState -) { - const migrationsDirectory = app.directoriesMap.get('migrations') || 'database' - const migrationPath = join(migrationsDirectory, `${Date.now()}_${state.tokensTableName}.ts`) - - const template = new sink.files.MustacheFile( - projectRoot, - migrationPath, - getStub('migrations/api_tokens.txt') - ) - if (template.exists()) { - sink.logger.action('create').skipped(`${migrationPath} file already exists`) - return - } - - template.apply(state).commit() - sink.logger.action('create').succeeded(migrationPath) -} - -/** - * Create the middleware(s) - */ -function makeMiddleware( - projectRoot: string, - app: ApplicationContract, - sink: typeof sinkStatic, - state: InstructionsState -) { - const middlewareDirectory = app.resolveNamespaceDirectory('middleware') || 'app/Middleware' - - /** - * Auth middleware - */ - const authPath = join(middlewareDirectory, 'Auth.ts') - const authTemplate = new sink.files.MustacheFile( - projectRoot, - authPath, - getStub('middleware/Auth.txt') - ) - if (authTemplate.exists()) { - sink.logger.action('create').skipped(`${authPath} file already exists`) - } else { - authTemplate.apply(state).commit() - sink.logger.action('create').succeeded(authPath) - } - - /** - * Silent auth middleware - */ - const silentAuthPath = join(middlewareDirectory, 'SilentAuth.ts') - const silentAuthTemplate = new sink.files.MustacheFile( - projectRoot, - silentAuthPath, - getStub('middleware/SilentAuth.txt') - ) - if (silentAuthTemplate.exists()) { - sink.logger.action('create').skipped(`${silentAuthPath} file already exists`) - } else { - silentAuthTemplate.apply(state).commit() - sink.logger.action('create').succeeded(silentAuthPath) - } -} - -/** - * Creates the contract file - */ -function makeContract( - projectRoot: string, - app: ApplicationContract, - sink: typeof sinkStatic, - state: InstructionsState -) { - const contractsDirectory = app.directoriesMap.get('contracts') || 'contracts' - const contractPath = join(contractsDirectory, 'auth.ts') - - const template = new sink.files.MustacheFile( - projectRoot, - contractPath, - getStub('contract/auth.txt') - ) - template.overwrite = true - - const partials: any = { - provider: getStub(CONTRACTS_PARTIALS_BASE, `user-provider-${state.provider}.txt`), - } - - state.guards.forEach((guard) => { - partials[`${guard}_guard`] = getStub(CONTRACTS_PARTIALS_BASE, `${guard}-guard.txt`) - }) - - template.apply(state).partials(partials).commit() - sink.logger.action('create').succeeded(contractPath) -} - -/** - * Makes the auth config file - */ -function makeConfig( - projectRoot: string, - app: ApplicationContract, - sink: typeof sinkStatic, - state: InstructionsState -) { - const configDirectory = app.directoriesMap.get('config') || 'config' - const configPath = join(configDirectory, 'auth.ts') - - const template = new sink.files.MustacheFile(projectRoot, configPath, getStub('config/auth.txt')) - template.overwrite = true - - const partials: any = { - provider: getStub(CONFIG_PARTIALS_BASE, `user-provider-${state.provider}.txt`), - token_provider: getStub(CONFIG_PARTIALS_BASE, `tokens-provider-${state.tokensProvider}.txt`), - } - - state.guards.forEach((guard) => { - partials[`${guard}_guard`] = getStub(CONFIG_PARTIALS_BASE, `${guard}-guard.txt`) - }) - - template.apply(state).partials(partials).commit() - sink.logger.action('create').succeeded(configPath) -} - -/** - * Prompts user to select the provider - */ -async function getProvider(sink: typeof sinkStatic) { - return sink.getPrompt().choice('Select provider for finding users', PROVIDER_PROMPT_CHOICES, { - validate(choice) { - return choice && choice.length ? true : 'Select the provider for finding users' - }, - }) -} - -/** - * Prompts user to select the tokens provider - */ -async function getTokensProvider(sink: typeof sinkStatic) { - return sink - .getPrompt() - .choice('Select the provider for storing API tokens', TOKENS_PROVIDER_PROMPT_CHOICES, { - validate(choice) { - return choice && choice.length ? true : 'Select the provider for storing API tokens' - }, - }) -} - -/** - * Prompts user to select one or more guards - */ -async function getGuard(sink: typeof sinkStatic) { - return sink - .getPrompt() - .multiple( - 'Select which guard you need for authentication (select using space)', - GUARD_PROMPT_CHOICES, - { - validate(choices) { - return choices && choices.length - ? true - : 'Select one or more guards for authenticating users' - }, - } - ) -} - -/** - * Prompts user for the model name - */ -async function getModelName(sink: typeof sinkStatic): Promise { - return sink.getPrompt().ask('Enter model name to be used for authentication', { - validate(value) { - return !!value.trim().length - }, - }) -} - -/** - * Prompts user for the table name - */ -async function getTableName(sink: typeof sinkStatic): Promise { - return sink.getPrompt().ask('Enter the database table name to look up users', { - validate(value) { - return !!value.trim().length - }, - }) -} - -/** - * Prompts user for the table name - */ -async function getMigrationConsent(sink: typeof sinkStatic, tableName: string): Promise { - return sink - .getPrompt() - .confirm(`Create migration for the ${sink.logger.colors.underline(tableName)} table?`) -} - -/** - * Instructions to be executed when setting up the package. - */ -export default async function instructions( - projectRoot: string, - app: ApplicationContract, - sink: typeof sinkStatic -) { - const state: InstructionsState = { - usersTableName: '', - tokensTableName: 'api_tokens', - tokensSchemaName: 'ApiTokens', - usersSchemaName: '', - provider: 'lucid', - tokensProvider: 'database', - guards: [], - hasGuard: { - web: false, - api: false, - basic: false, - }, - } - - state.provider = await getProvider(sink) - state.guards = await getGuard(sink) - - /** - * Need booleans for mustache templates - */ - state.guards.forEach((guard) => (state.hasGuard[guard] = true)) - - /** - * Make model when provider is lucid otherwise prompt for the database - * table name - */ - if (state.provider === 'lucid') { - const modelName = await getModelName(sink) - state.modelName = string.pascalCase(string.singularize(modelName.replace(/(\.ts|\.js)$/, ''))) - state.usersTableName = string.pluralize(string.snakeCase(state.modelName)) - state.modelReference = string.camelCase(string.singularize(state.modelName)) - state.modelNamespace = `${app.namespacesMap.get('models') || 'App/Models'}/${state.modelName}` - } else { - state.usersTableName = await getTableName(sink) - } - - const usersMigrationConsent = await getMigrationConsent(sink, state.usersTableName) - let tokensMigrationConsent = false - - /** - * Only ask for the consent when using the api guard - */ - if (state.hasGuard.api) { - state.tokensProvider = await getTokensProvider(sink) - if (state.tokensProvider === 'database') { - tokensMigrationConsent = await getMigrationConsent(sink, state.tokensTableName) - } - } - - /** - * Pascal case - */ - const camelCaseSchemaName = string.camelCase(`${state.usersTableName}_schema`) - state.usersSchemaName = `${camelCaseSchemaName - .charAt(0) - .toUpperCase()}${camelCaseSchemaName.slice(1)}` - - /** - * Make model when prompted for it - */ - if (state.modelName) { - makeModel(projectRoot, app, sink, state) - } - - /** - * Make users migration file - */ - if (usersMigrationConsent) { - makeUsersMigration(projectRoot, app, sink, state) - } - - /** - * Make tokens migration file - */ - if (tokensMigrationConsent) { - makeTokensMigration(projectRoot, app, sink, state) - } - - /** - * Make contract file - */ - makeContract(projectRoot, app, sink, state) - - /** - * Make config file - */ - makeConfig(projectRoot, app, sink, state) - - /** - * Make middleware - */ - makeMiddleware(projectRoot, app, sink, state) -} diff --git a/package.json b/package.json index 9cdcfec..c1f632b 100644 --- a/package.json +++ b/package.json @@ -2,38 +2,51 @@ "name": "@adonisjs/auth", "version": "8.2.3", "description": "Official authentication provider for Adonis framework", - "types": "build/adonis-typings/index.d.ts", - "main": "build/providers/AuthProvider.js", + "type": "module", + "main": "build/index.js", "files": [ - "build/adonis-typings", - "build/providers", - "build/templates", + "build/configure.js", + "build/configure.d.ts", + "build/index.js", + "build/index.d.ts", "build/src", - "build/instructions.js", - "build/standalone.js", - "build/standalone.d.ts" + "build/services", + "build/providers", + "build/factories", + "build/stubs", + "build/index.d.ts", + "build/index.js" ], + "engines": { + "node": ">=18.16.0" + }, + "exports": { + ".": "./build/index.js", + "./services/main": "./build/services/main.js", + "./auth_provider": "./build/providers/auth_provider.js", + "./factories": "./build/factories/main.js", + "./types": "./build/src/types/main.js" + }, "scripts": { - "mrm": "mrm --preset=@adonisjs/mrm-preset", "pretest": "npm run lint", - "test": "node -r @adonisjs/require-ts/build/register ./bin/test.ts", + "test": "c8 npm run vscode:test", + "quick:test": "node --enable-source-maps --loader=ts-node/esm ./bin/test.js", "clean": "del-cli build", - "copyfiles": "copyfiles \"templates/**/*.txt\" build", - "compile": "npm run lint && npm run clean && tsc", - "build": "npm run compile && npm run copyfiles", - "commit": "git-cz", + "copy:templates": "copyfiles \"stubs/**/**/*.stub\" build", + "compile": "npm run lint && npm run clean && tsc && npm run copy:templates", + "build": "npm run compile", "release": "np", "version": "npm run build", - "lint": "eslint . --ext=.ts", "prepublishOnly": "npm run build", - "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json adonisjs/auth", + "lint": "eslint . --ext=.ts", + "typecheck": "tsc --noEmit", + "sync-labels": "github-label-sync --labels .github/labels.json adonisjs/auth", "format": "prettier --write ." }, "keywords": [ - "adonis", - "adonis-framework", - "adonis-auth", - "authentication" + "adonisjs", + "authentication", + "auth" ], "author": "adonisjs,virk", "license": "MIT", @@ -46,95 +59,50 @@ "url": "https://github.com/adonisjs/auth/issues" }, "devDependencies": { - "@adonisjs/core": "^5.8.8", - "@adonisjs/i18n": "^1.5.6", - "@adonisjs/lucid": "^18.2.0", - "@adonisjs/mrm-preset": "^5.0.3", - "@adonisjs/redis": "^7.3.1", - "@adonisjs/repl": "^3.1.11", - "@adonisjs/require-ts": "^2.0.13", - "@adonisjs/session": "^6.4.0", - "@adonisjs/sink": "^5.4.1", - "@japa/assert": "^1.3.6", - "@japa/preset-adonis": "^1.2.0", - "@japa/run-failed-tests": "^1.1.0", - "@japa/runner": "^2.2.2", - "@japa/spec-reporter": "^1.3.2", - "@poppinss/dev-utils": "^2.0.3", - "@types/node": "^18.11.2", - "@types/supertest": "^2.0.12", + "@adonisjs/eslint-config": "^1.1.8", + "@adonisjs/prettier-config": "^1.1.8", + "@adonisjs/tsconfig": "^1.1.8", + "@commitlint/cli": "^17.7.2", + "@commitlint/config-conventional": "^17.7.0", + "@swc/core": "1.3.82", + "@types/node": "^20.8.3", + "c8": "^8.0.1", "copyfiles": "^2.4.1", - "del-cli": "^5.0.0", + "del-cli": "^5.1.0", "eslint": "^8.25.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-adonis": "^2.1.1", - "eslint-plugin-prettier": "^4.2.1", - "github-label-sync": "^2.2.0", - "husky": "^8.0.1", - "mrm": "^4.1.13", - "np": "^7.6.2", - "phc-bcrypt": "^1.0.7", - "pino-pretty": "^9.1.1", - "prettier": "^2.7.1", - "reflect-metadata": "^0.1.13", - "set-cookie-parser": "^2.5.1", - "sqlite3": "^5.1.2", - "supertest": "^6.3.0", - "ts-essentials": "^9.3.0", - "typescript": "^4.8.4" + "github-label-sync": "^2.3.1", + "husky": "^8.0.3", + "np": "^8.0.4", + "prettier": "^3.0.3", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" }, - "nyc": { - "exclude": [ - "test" - ], - "extension": [ - ".ts" + "prettier": "@adonisjs/prettier-config", + "eslintConfig": { + "extends": "@adonisjs/eslint-config/package" + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" ] }, - "config": { - "commitizen": { - "path": "cz-conventional-changelog" - } + "publishConfig": { + "access": "public", + "tag": "next" }, "np": { - "contents": ".", + "message": "chore(release): %s", + "tag": "next", + "branch": "main", "anyBranch": false }, - "dependencies": { - "@poppinss/hooks": "^5.0.3", - "@poppinss/utils": "^5.0.0", - "luxon": "^3.0.4" - }, - "peerDependencies": { - "@adonisjs/core": "^5.7.1", - "@adonisjs/i18n": "^1.5.0", - "@adonisjs/lucid": "^18.0.0", - "@adonisjs/redis": "^7.2.0", - "@adonisjs/session": "^6.2.0" - }, - "peerDependenciesMeta": { - "@adonisjs/i18n": { - "optional": true - }, - "@adonisjs/lucid": { - "optional": true - }, - "@adonisjs/session": { - "optional": true - }, - "@adonisjs/redis": { - "optional": true - } - }, - "publishConfig": { - "access": "public", - "tag": "latest" - }, - "adonisjs": { - "instructions": "./build/instructions.js", - "types": "@adonisjs/auth", - "providers": [ - "@adonisjs/auth" + "c8": { + "reporter": [ + "text", + "html" + ], + "exclude": [ + "tests/**" ] } } diff --git a/providers/AuthProvider.ts b/providers/AuthProvider.ts deleted file mode 100644 index 63df59e..0000000 --- a/providers/AuthProvider.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -/** - * Auth provider to register the auth binding - */ -export default class AuthProvider { - constructor(protected application: ApplicationContract) {} - public static needsApplication = true - - /** - * Register auth binding - */ - public register() { - this.application.container.singleton('Adonis/Addons/Auth', () => { - const authConfig = this.application.container - .resolveBinding('Adonis/Core/Config') - .get('auth', {}) - const { AuthManager } = require('../src/AuthManager') - return new AuthManager(this.application, authConfig) - }) - } - - /** - * Sharing the auth object with HTTP context - */ - protected registerAuthWithHttpContext() { - this.application.container.withBindings( - ['Adonis/Core/HttpContext', 'Adonis/Addons/Auth'], - (HttpContext, Auth) => { - HttpContext.getter( - 'auth', - function auth() { - return Auth.getAuthForRequest(this) - }, - true - ) - } - ) - } - - /** - * Sharing auth with all the templates - */ - protected shareAuthWithViews() { - this.application.container.withBindings( - ['Adonis/Core/Server', 'Adonis/Core/View'], - (Server) => { - Server.hooks.before(async (ctx) => { - ctx['view'].share({ auth: ctx.auth }) - }) - } - ) - } - - /** - * Register test bindings - */ - protected registerTestBindings() { - this.application.container.withBindings( - ['Japa/Preset/ApiRequest', 'Japa/Preset/ApiClient', 'Adonis/Addons/Auth'], - (ApiRequest, ApiClient, Auth) => { - const { defineTestsBindings } = require('../src/Bindings/Tests') - return defineTestsBindings(ApiRequest, ApiClient, Auth) - } - ) - } - - /** - * Hook into boot to register auth macro - */ - public async boot() { - this.registerAuthWithHttpContext() - this.shareAuthWithViews() - this.registerTestBindings() - } -} diff --git a/src/Auth/index.ts b/src/Auth/index.ts deleted file mode 100644 index 1bcc8ac..0000000 --- a/src/Auth/index.ts +++ /dev/null @@ -1,174 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' -import { AuthContract, AuthManagerContract } from '@ioc:Adonis/Addons/Auth' - -/** - * Auth class exposes the API to obtain guard instances for a given - * HTTP request. - */ -export class Auth implements AuthContract { - /** - * We keep a per request singleton instances for each instantiated mapping - */ - private mappingsCache: Map = new Map() - - /** - * The default guard is always the one defined inside the config, until - * manually overwritten by the user - */ - public defaultGuard: string = this.manager.defaultGuard - - constructor(private manager: AuthManagerContract, private ctx: HttpContextContract) {} - - /** - * Returns an instance of a named or the default mapping - */ - public use(mapping?: string) { - mapping = mapping || this.defaultGuard - - if (!this.mappingsCache.has(mapping)) { - this.ctx.logger.trace('instantiating auth mapping', { name: mapping }) - this.mappingsCache.set(mapping, this.manager.makeMapping(this.ctx, mapping)) - } - - return this.mappingsCache.get(mapping)! - } - - /** - * Guard name for the default mapping - */ - public get name() { - return this.use().name - } - - /** - * Reference to the logged in user - */ - public get user() { - return this.use().user - } - - /** - * Reference to the default guard config - */ - public get config() { - return this.use().config - } - - /** - * Find if the user has been logged out in the current request - */ - public get isLoggedOut() { - return this.use().isLoggedOut - } - - /** - * A boolean to know if user is a guest or not. It is - * always opposite of [[isLoggedIn]] - */ - public get isGuest() { - return this.use().isGuest - } - - /** - * A boolean to know if user is logged in or not - */ - public get isLoggedIn() { - return this.use().isLoggedIn - } - - /** - * A boolean to know if user is retrieved by authenticating - * the current request or not. - */ - public get isAuthenticated() { - return this.use().isAuthenticated - } - - /** - * Whether or not the authentication has been attempted - * for the current request - */ - public get authenticationAttempted() { - return this.use().authenticationAttempted - } - - /** - * Reference to the provider for looking up the user - */ - public get provider() { - return this.use().provider - } - - /** - * Verify user credentials. - */ - public async verifyCredentials(uid: string, password: string) { - return this.use().verifyCredentials(uid, password) - } - - /** - * Attempt to verify user credentials and perform login - */ - public async attempt(uid: string, password: string, ...args: any[]) { - return this.use().attempt(uid, password, ...args) - } - - /** - * Login a user without any verification - */ - public async login(user: any, ...args: any[]) { - return this.use().login(user, ...args) - } - - /** - * Login a user using their id - */ - public async loginViaId(id: string | number, ...args: any[]) { - return this.use().loginViaId(id, ...args) - } - - /** - * Attempts to authenticate the user for the current HTTP request. An exception - * is raised when unable to do so - */ - public async authenticate() { - return this.use().authenticate() - } - - /** - * Attempts to authenticate the user for the current HTTP request and supresses - * exceptions raised by the [[authenticate]] method and returns a boolean - */ - public async check() { - return this.use().check() - } - - /** - * Logout user - */ - public async logout(...args: any[]) { - return this.use().logout(...args) - } - - /** - * Serialize toJSON - */ - public toJSON(): any { - return { - defaultGuard: this.defaultGuard, - guards: [...this.mappingsCache.keys()].reduce((result, key) => { - result[key] = this.mappingsCache.get(key).toJSON() - return result - }, {}), - } - } -} diff --git a/src/AuthManager/index.ts b/src/AuthManager/index.ts deleted file mode 100644 index bf55024..0000000 --- a/src/AuthManager/index.ts +++ /dev/null @@ -1,376 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception, ManagerConfigValidator } from '@poppinss/utils' - -import { - AuthConfig, - GuardsList, - OATGuardConfig, - SessionGuardConfig, - LucidProviderConfig, - AuthManagerContract, - ExtendGuardCallback, - BasicAuthGuardConfig, - UserProviderContract, - DatabaseProviderConfig, - ExtendProviderCallback, - ExtendClientCallback, -} from '@ioc:Adonis/Addons/Auth' - -import { ApplicationContract } from '@ioc:Adonis/Core/Application' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -import { Auth } from '../Auth' - -/** - * Auth manager to manage guards and providers object. The extend API can - * be used to add custom guards and providers - */ -export class AuthManager implements AuthManagerContract { - /** - * Extended set of testing clients - */ - private extendedClients: Map = new Map() - - /** - * Extended set of providers - */ - private extendedProviders: Map = new Map() - - /** - * Extend set of guards - */ - private extendedGuards: Map = new Map() - - /** - * Reference to the default guard - */ - public defaultGuard = this.config.guard - - constructor(public application: ApplicationContract, private config: AuthConfig) { - const validator = new ManagerConfigValidator(config, 'auth', 'config/auth') - validator.validateDefault('guard') - validator.validateList('guards', 'guard') - } - - /** - * Verifies and returns an instance of the event emitter - */ - private getEmitter() { - const hasEmitter = this.application.container.hasBinding('Adonis/Core/Event') - if (!hasEmitter) { - throw new Exception('"Adonis/Core/Event" is required by the auth provider') - } - - return this.application.container.use('Adonis/Core/Event') - } - - /** - * Lazily makes an instance of the lucid provider - */ - private makeLucidProvider(config: LucidProviderConfig) { - return new (require('../UserProviders/Lucid').LucidProvider)(this.application, config) - } - - /** - * Lazily makes an instance of the database provider - */ - private makeDatabaseProvider(config: DatabaseProviderConfig) { - const Database = this.application.container.use('Adonis/Lucid/Database') - return new (require('../UserProviders/Database').DatabaseProvider)( - this.application, - config, - Database - ) - } - - /** - * Returns an instance of the extended provider - */ - private makeExtendedProvider(mapping: string, config: any) { - const providerCallback = this.extendedProviders.get(config.driver) - if (!providerCallback) { - throw new Exception(`Invalid provider "${config.driver}"`) - } - - return providerCallback(this, mapping, config) - } - - /** - * Lazily makes an instance of the token database provider - */ - private makeTokenDatabaseProvider(config: DatabaseProviderConfig) { - const Database = this.application.container.use('Adonis/Lucid/Database') - return new (require('../TokenProviders/Database').TokenDatabaseProvider)(config, Database) - } - - /** - * Lazily makes an instance of the token redis provider - */ - private makeTokenRedisProvider(config: DatabaseProviderConfig) { - if (!this.application.container.hasBinding('Adonis/Addons/Redis')) { - throw new Exception('"@adonisjs/redis" is required to use the "redis" token provider') - } - - const Redis = this.application.container.use('Adonis/Addons/Redis') - return new (require('../TokenProviders/Redis').TokenRedisProvider)(config, Redis) - } - - /** - * Returns an instance of the session guard - */ - private makeSessionGuard( - mapping: string, - config: SessionGuardConfig, - provider: UserProviderContract, - ctx: HttpContextContract - ) { - const { SessionGuard } = require('../Guards/Session') - return new SessionGuard(mapping, config, this.getEmitter(), provider, ctx) - } - - /** - * Returns an instance of the session guard - */ - private makeOatGuard( - mapping: string, - config: OATGuardConfig, - provider: UserProviderContract, - ctx: HttpContextContract - ) { - const { OATGuard } = require('../Guards/Oat') - const tokenProvider = this.makeTokenProviderInstance(config.tokenProvider) - return new OATGuard(mapping, config, this.getEmitter(), provider, ctx, tokenProvider) - } - - /** - * Returns an instance of the basic auth guard - */ - private makeBasicAuthGuard( - mapping: string, - config: BasicAuthGuardConfig, - provider: UserProviderContract, - ctx: HttpContextContract - ) { - const { BasicAuthGuard } = require('../Guards/BasicAuth') - return new BasicAuthGuard(mapping, config, this.getEmitter(), provider, ctx) - } - - /** - * Returns an instance of the extended guard - */ - private makeExtendedGuard( - mapping: string, - config: any, - provider: UserProviderContract, - ctx: HttpContextContract - ) { - const guardCallback = this.extendedGuards.get(config.driver) - if (!guardCallback) { - throw new Exception(`Invalid guard driver "${config.driver}" property`) - } - - return guardCallback(this, mapping, config, provider, ctx) - } - - /** - * Returns an instance of the session client - */ - private makeSessionClient( - mapping: string, - config: SessionGuardConfig, - provider: UserProviderContract - ) { - const { SessionClient } = require('../Clients/Session') - return new SessionClient(mapping, config, provider) - } - - /** - * Returns an instance of the session client - */ - private makeOatClient( - mapping: string, - config: OATGuardConfig, - provider: UserProviderContract - ) { - const { OATClient } = require('../Clients/Oat') - const tokenProvider = this.makeTokenProviderInstance(config.tokenProvider) - return new OATClient(mapping, config, provider, tokenProvider) - } - - /** - * Returns an instance of the extended client - */ - private makeExtendedClient(mapping: string, config: any, provider: UserProviderContract) { - const clientCallback = this.extendedClients.get(config.driver) - if (!clientCallback) { - throw new Exception(`Invalid guard driver "${config.driver}" property`) - } - - return clientCallback(this, mapping, config, provider) - } - - /** - * Makes client instance for the defined driver inside the - * mapping config. - */ - private makeClientInstance( - mapping: string, - mappingConfig: any, - provider: UserProviderContract - ) { - if (!mappingConfig || !mappingConfig.driver) { - throw new Exception('Invalid auth config, missing "driver" property') - } - - switch (mappingConfig.driver) { - case 'session': - return this.makeSessionClient(mapping, mappingConfig, provider) - case 'oat': - return this.makeOatClient(mapping, mappingConfig, provider) - case 'basic': - throw new Exception( - 'There is no testing client for basic auth. Use "request.basicAuth" method instead' - ) - default: - return this.makeExtendedClient(mapping, mappingConfig, provider) - } - } - - /** - * Makes instance of a provider based upon the driver value - */ - private makeUserProviderInstance(mapping: string, providerConfig: any) { - if (!providerConfig || !providerConfig.driver) { - throw new Exception('Invalid auth config, missing "provider" or "provider.driver" property') - } - - switch (providerConfig.driver) { - case 'lucid': - return this.makeLucidProvider(providerConfig) - case 'database': - return this.makeDatabaseProvider(providerConfig) - default: - return this.makeExtendedProvider(mapping, providerConfig) - } - } - - /** - * Makes instance of a provider based upon the driver value - */ - private makeTokenProviderInstance(providerConfig: any) { - if (!providerConfig || !providerConfig.driver) { - throw new Exception( - 'Invalid auth config, missing "tokenProvider" or "tokenProvider.driver" property' - ) - } - - switch (providerConfig.driver) { - case 'database': - return this.makeTokenDatabaseProvider(providerConfig) - case 'redis': - return this.makeTokenRedisProvider(providerConfig) - default: - throw new Exception(`Invalid token provider "${providerConfig.driver}"`) - } - } - - /** - * Makes guard instance for the defined driver inside the - * mapping config. - */ - private makeGuardInstance( - mapping: string, - mappingConfig: any, - provider: UserProviderContract, - ctx: HttpContextContract - ) { - if (!mappingConfig || !mappingConfig.driver) { - throw new Exception('Invalid auth config, missing "driver" property') - } - - switch (mappingConfig.driver) { - case 'session': - return this.makeSessionGuard(mapping, mappingConfig, provider, ctx) - case 'oat': - return this.makeOatGuard(mapping, mappingConfig, provider, ctx) - case 'basic': - return this.makeBasicAuthGuard(mapping, mappingConfig, provider, ctx) - default: - return this.makeExtendedGuard(mapping, mappingConfig, provider, ctx) - } - } - - /** - * Make an instance of a given mapping for the current HTTP request. - */ - public makeMapping(ctx: HttpContextContract, mapping: keyof GuardsList) { - const mappingConfig = this.config.guards[mapping] - - if (mappingConfig === undefined) { - throw new Exception( - `Invalid guard "${mapping}". Make sure the guard is defined inside the config/auth file` - ) - } - - const provider = this.makeUserProviderInstance(mapping, mappingConfig.provider) - return this.makeGuardInstance(mapping, mappingConfig, provider, ctx) - } - - /** - * Returns an instance of the testing - */ - public client(mapping: keyof GuardsList) { - const mappingConfig = this.config.guards[mapping] - - if (mappingConfig === undefined) { - throw new Exception( - `Invalid guard "${mapping}". Make sure the guard is defined inside the config/auth file` - ) - } - - const provider = this.makeUserProviderInstance(mapping, mappingConfig.provider) - return this.makeClientInstance(mapping, mappingConfig, provider) - } - - /** - * Returns an instance of the auth class for the current request - */ - public getAuthForRequest(ctx: HttpContextContract) { - return new Auth(this, ctx) - } - - /** - * Extend auth by adding custom providers and guards - */ - public extend(type: 'provider', name: string, callback: ExtendProviderCallback): void - public extend(type: 'guard', name: string, callback: ExtendGuardCallback): void - public extend(type: 'client', name: string, callback: ExtendClientCallback): void - public extend( - type: 'provider' | 'guard' | 'client', - name: string, - callback: ExtendProviderCallback | ExtendGuardCallback | ExtendClientCallback - ) { - if (type === 'provider') { - this.extendedProviders.set(name, callback as ExtendProviderCallback) - return - } - - if (type === 'client') { - this.extendedClients.set(name, callback as ExtendClientCallback) - return - } - - if (type === 'guard') { - this.extendedGuards.set(name, callback as ExtendGuardCallback) - return - } - } -} diff --git a/src/Bindings/Tests.ts b/src/Bindings/Tests.ts deleted file mode 100644 index 4f6f59d..0000000 --- a/src/Bindings/Tests.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS Auth - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { AuthManagerContract } from '@ioc:Adonis/Addons/Auth' -import { ContainerBindings } from '@ioc:Adonis/Core/Application' - -/** - * Define test bindings - */ -export function defineTestsBindings( - ApiRequest: ContainerBindings['Japa/Preset/ApiRequest'], - ApiClient: ContainerBindings['Japa/Preset/ApiClient'], - AuthManager: AuthManagerContract -) { - /** - * Set "sessionClient" on the api request - */ - ApiRequest.getter( - 'authManager', - function () { - return AuthManager - }, - true - ) - - /** - * Login user using the default guard - */ - ApiRequest.macro('loginAs', function (user) { - this['authData'] = { - client: this.authManager.client(this.authManager.defaultGuard), - args: [user], - } - - return this - }) - - /** - * Login user using a custom guard - */ - ApiRequest.macro('guard', function (mapping) { - return { - loginAs: (...args: any[]) => { - this['authData'] = { - client: this.authManager.client(mapping), - args, - } - return this - }, - } - }) - - /** - * Hook into the request and login the user - */ - ApiClient.setup(async (request) => { - const authData = request['authData'] - if (!authData) { - return - } - - const requestData = await authData.client.login(...authData.args) - - if (requestData.headers) { - request.headers(requestData.headers) - } - if (requestData.session) { - request.session(requestData.session) - } - if (requestData.cookies) { - request.cookies(requestData.cookies) - } - - return async () => { - await authData.client.logout(...authData.args) - } - }) -} diff --git a/src/Clients/Oat/index.ts b/src/Clients/Oat/index.ts deleted file mode 100644 index 9ee5047..0000000 --- a/src/Clients/Oat/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { - OATGuardConfig, - OATLoginOptions, - OATClientContract, - UserProviderContract, - ProviderUserContract, - ClientRequestData, -} from '@ioc:Adonis/Addons/Auth' - -import { DateTime } from 'luxon' -import { createHash } from 'crypto' -import { Exception } from '@poppinss/utils' -import { string, base64 } from '@poppinss/utils/build/helpers' - -import { ProviderToken } from '../../Tokens/ProviderToken' -import { TokenProviderContract } from '@ioc:Adonis/Addons/Auth' - -/** - * OAT client to login a user during tests using the - * opaque tokens guard - */ -export class OATClient implements OATClientContract { - constructor( - public name: string, - public config: OATGuardConfig, - private provider: UserProviderContract, - public tokenProvider: TokenProviderContract - ) {} - - /** - * Token generated during the login call - */ - private tokenId?: string - - /** - * Length of the raw token. The hash length will vary - */ - private tokenLength = 60 - - /** - * Token type for the persistance store - */ - private tokenType = this.config.tokenProvider.type || 'opaque_token' - - /** - * Returns the provider user instance from the regular user details. Raises - * exception when id is missing - */ - private async getUserForLogin( - user: any, - identifierKey: string - ): Promise> { - const providerUser = await this.provider.getUserFor(user) - - /** - * Ensure id exists on the user - */ - const id = providerUser.getId() - if (!id) { - throw new Exception(`Cannot login user. Value of "${identifierKey}" is not defined`) - } - - return providerUser - } - - /** - * Converts value to a sha256 hash - */ - private generateHash(token: string) { - return createHash('sha256').update(token).digest('hex') - } - - /** - * Converts expiry duration to an absolute date/time value - */ - private getExpiresAtDate(expiresIn?: string | number) { - if (!expiresIn) { - return - } - - const milliseconds = typeof expiresIn === 'string' ? string.toMs(expiresIn) : expiresIn - return DateTime.local().plus({ milliseconds }) - } - - /** - * Generates a new token + hash for the persistance - */ - private generateTokenForPersistance(expiresIn?: string | number) { - const token = string.generateRandom(this.tokenLength) - - return { - token, - hash: this.generateHash(token), - expiresAt: this.getExpiresAtDate(expiresIn), - } - } - - /** - * Returns the request data to mark user as logged in - */ - public async login(user: any, options?: OATLoginOptions): Promise { - /** - * Normalize options with defaults - */ - const { expiresIn, name, ...meta } = Object.assign( - { - name: 'Opaque Access Token', - }, - options - ) - - /** - * Since the login method is not exposed to the end user, we cannot expect - * them to instantiate and pass an instance of provider user, so we - * create one manually. - */ - const providerUser = await this.getUserForLogin(user, this.config.provider.identifierKey) - - /** - * "getUserForLogin" raises exception when id is missing, so we can - * safely assume it is defined - */ - const id = providerUser.getId()! - const token = this.generateTokenForPersistance(expiresIn) - - /** - * Persist token to the database. Make sure that we are always - * passing the hash to the storage driver - */ - const providerToken = new ProviderToken(name, token.hash, id, this.tokenType) - providerToken.expiresAt = token.expiresAt - providerToken.meta = meta - this.tokenId = await this.tokenProvider.write(providerToken) - - return { - headers: { - Authorization: `Bearer ${base64.urlEncode(this.tokenId)}.${token.token}`, - }, - } - } - - /** - * Logout user - */ - public async logout() { - if (this.tokenId) { - await this.tokenProvider.destroy(this.tokenId, this.tokenType) - } - } -} diff --git a/src/Clients/Session/index.ts b/src/Clients/Session/index.ts deleted file mode 100644 index 512214e..0000000 --- a/src/Clients/Session/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { - SessionGuardConfig, - GuardClientContract, - UserProviderContract, - ProviderUserContract, -} from '@ioc:Adonis/Addons/Auth' -import { Exception } from '@poppinss/utils' - -/** - * Session client to login a user during tests using the - * sessions guard - */ -export class SessionClient implements GuardClientContract { - constructor( - public name: string, - private config: SessionGuardConfig, - private provider: UserProviderContract - ) {} - - /** - * The name of the session key name - */ - public get sessionKeyName() { - return `auth_${this.name}` - } - - /** - * Returns the provider user instance from the regular user details. Raises - * exception when id is missing - */ - protected async getUserForLogin( - user: any, - identifierKey: string - ): Promise> { - const providerUser = await this.provider.getUserFor(user) - - /** - * Ensure id exists on the user - */ - const id = providerUser.getId() - if (!id) { - throw new Exception(`Cannot login user. Value of "${identifierKey}" is not defined`) - } - - return providerUser - } - - /** - * Returns the request data to mark user as logged in - */ - public async login(user: any) { - /** - * Since the login method is exposed to the end user, we cannot expect - * them to instantiate and return an instance of authenticatable, so - * we create one manually. - */ - const providerUser = await this.getUserForLogin(user, this.config.provider.identifierKey) - - /** - * getUserForLogin raises exception when id is missing, so we can - * safely assume it is defined - */ - const id = providerUser.getId()! - - return { - session: { - [this.sessionKeyName]: id, - }, - } - } - - /** - * No need to logout when using session client. - * Session data is persisted within memory and will - * be cleared after each test - */ - public async logout() {} -} diff --git a/src/Exceptions/AuthenticationException.ts b/src/Exceptions/AuthenticationException.ts deleted file mode 100644 index b30af98..0000000 --- a/src/Exceptions/AuthenticationException.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import { GuardsList } from '@ioc:Adonis/Addons/Auth' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -/** - * Exception raised when unable to authenticate user session - */ -export class AuthenticationException extends Exception { - public guard: string - public redirectTo: string = '/login' - public responseText = this.message - - /** - * Raise exception with message and redirect url - */ - constructor(message: string, code: string, guard?: string, redirectTo?: string) { - super(message, 401, code) - if (redirectTo) { - this.redirectTo = redirectTo - } - - if (guard) { - this.guard = guard - } - } - - /** - * Prompts user to enter credentials - */ - protected respondWithBasicAuthPrompt(ctx: HttpContextContract, realm?: string) { - realm = realm || 'Authenticate' - - ctx.response - .status(this.status) - .header('WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`) - .send(this.responseText) - } - - /** - * Send response as an array of errors - */ - protected respondWithJson(ctx: HttpContextContract) { - ctx.response.status(this.status).send({ - errors: [ - { - message: this.responseText, - }, - ], - }) - } - - /** - * Flash error message and redirect the user back - */ - protected respondWithRedirect(ctx: HttpContextContract) { - if (!ctx.session) { - return ctx.response.status(this.status).send(this.responseText) - } - - ctx.session.flashExcept(['_csrf']) - ctx.session.flash('auth', { error: this.responseText }) - ctx.response.redirect(this.redirectTo, true) - } - - /** - * Send response as an array of errors formatted as per JSONAPI spec - */ - protected respondWithJsonAPI(ctx: HttpContextContract) { - ctx.response.status(this.status).send({ - errors: [ - { - code: this.code, - title: this.responseText, - source: null, - }, - ], - }) - } - - /** - * Missing session or unable to lookup user from session - */ - public static invalidSession(guard: string) { - return new this('Invalid session', 'E_INVALID_AUTH_SESSION', guard) - } - - /** - * Missing/Invalid token or unable to lookup user from the token - */ - public static invalidToken(guard: string) { - return new this('Invalid API token', 'E_INVALID_API_TOKEN', guard) - } - - /** - * Missing or invalid basic auth credentials - */ - public static invalidBasicCredentials(guard: string) { - return new this('Invalid basic auth credentials', 'E_INVALID_BASIC_CREDENTIALS', guard) - } - - /** - * Self handle exception and attempt to make the best response based - * upon the type of request - */ - public async handle(_: AuthenticationException, ctx: HttpContextContract) { - /** - * We need access to the guard config and driver to make appropriate response - */ - const config = this.guard ? ctx.auth.use(this.guard as keyof GuardsList).config : null - - /** - * Use translation when using i18n - */ - if ('i18n' in ctx) { - this.responseText = ctx.i18n.formatMessage(`auth.${this.code}`, {}, this.message) - } - - /** - * Show username, password prompt when using basic auth driver - */ - if (config && config.driver === 'basic') { - this.respondWithBasicAuthPrompt(ctx, config.realm) - return - } - - /** - * Respond with json for ajax requests - */ - if (ctx.request.ajax()) { - this.respondWithJson(ctx) - return - } - - /** - * Uses content negotiation to make the response - */ - switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { - case 'html': - case null: - this.respondWithRedirect(ctx) - break - case 'json': - this.respondWithJson(ctx) - break - case 'application/vnd.api+json': - this.respondWithJsonAPI(ctx) - break - } - } -} diff --git a/src/Exceptions/InvalidCredentialsException.ts b/src/Exceptions/InvalidCredentialsException.ts deleted file mode 100644 index 8e3d967..0000000 --- a/src/Exceptions/InvalidCredentialsException.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -/** - * Exception raised when unable to verify user credentials - */ -export class InvalidCredentialsException extends Exception { - public guard: string - public responseText = this.message - - /** - * Unable to find user - */ - public static invalidUid(guard: string) { - const error = new this('User not found', 400, 'E_INVALID_AUTH_UID') - error.guard = guard - return error - } - - /** - * Invalid user password - */ - public static invalidPassword(guard: string) { - const error = new this('Password mis-match', 400, 'E_INVALID_AUTH_PASSWORD') - error.guard = guard - return error - } - - /** - * Send response as an array of errors - */ - protected respondWithJson(ctx: HttpContextContract) { - ctx.response.status(this.status).send({ - errors: [ - { - message: this.responseText, - }, - ], - }) - } - - /** - * Flash error message and redirect the user back - */ - protected respondWithRedirect(ctx: HttpContextContract) { - if (!ctx.session) { - return ctx.response.status(this.status).send(this.responseText) - } - - ctx.session.flashExcept(['_csrf']) - ctx.session.flash('auth', { - error: this.responseText, - - /** - * Will be removed in the future - */ - errors: { - uid: this.code === 'E_INVALID_AUTH_UID' ? ['Invalid login id'] : null, - password: this.code === 'E_INVALID_AUTH_PASSWORD' ? ['Invalid password'] : null, - }, - }) - ctx.response.redirect('back', true) - } - - /** - * Send response as an array of errors formatted as per JSONAPI spec - */ - protected respondWithJsonAPI(ctx: HttpContextContract) { - ctx.response.status(this.status).send({ - errors: [ - { - code: this.code, - title: this.responseText, - source: null, - }, - ], - }) - } - - /** - * Self handle exception and attempt to make the best response based - * upon the type of request - */ - public async handle(_: InvalidCredentialsException, ctx: HttpContextContract) { - /** - * Use translation when using i18n - */ - if ('i18n' in ctx) { - this.responseText = ctx.i18n.formatMessage(`auth.${this.code}`, {}, this.message) - } - - if (ctx.request.ajax()) { - this.respondWithJson(ctx) - return - } - - switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { - case 'html': - case null: - this.respondWithRedirect(ctx) - break - case 'json': - this.respondWithJson(ctx) - break - case 'application/vnd.api+json': - this.respondWithJsonAPI(ctx) - break - } - } -} diff --git a/src/Guards/Base/index.ts b/src/Guards/Base/index.ts deleted file mode 100644 index d046825..0000000 --- a/src/Guards/Base/index.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import { UserProviderContract, ProviderUserContract, GuardsList } from '@ioc:Adonis/Addons/Auth' - -import { InvalidCredentialsException } from '../../Exceptions/InvalidCredentialsException' - -/** - * Base guard with shared abilities - */ -export abstract class BaseGuard { - constructor( - public name: Guard, - public config: GuardsList[Guard]['config'], - public provider: UserProviderContract - ) {} - - /** - * Reference to the name of the guard driver - */ - public get driver() { - return this.config.driver - } - - /** - * Whether or not the authentication has been attempted - * for the current request - */ - public authenticationAttempted = false - - /** - * Find if the user has been logged out in the current request - */ - public isLoggedOut = false - - /** - * A boolean to know if user is retrieved by authenticating - * the current request or not - */ - public isAuthenticated = false - - /** - * A boolean to know if user is loggedin via remember me token - * or not. - */ - public viaRemember = false - - /** - * Logged in or authenticated user - */ - public user?: any - - /** - * Accessor to know if user is logged in - */ - public get isLoggedIn() { - return !!this.user - } - - /** - * Accessor to know if user is a guest. It is always opposite - * of [[isLoggedIn]] - */ - public get isGuest() { - return !this.isLoggedIn - } - - /** - * Lookup user using UID - */ - private async lookupUsingUid(uid: string): Promise> { - const providerUser = await this.provider.findByUid(uid) - if (!providerUser.user) { - throw InvalidCredentialsException.invalidUid(this.name) - } - - return providerUser - } - - /** - * Verify user password - */ - private async verifyPassword( - providerUser: ProviderUserContract, - password: string - ): Promise { - /** - * Verify password or raise exception - */ - const verified = await providerUser.verifyPassword(password) - if (!verified) { - throw InvalidCredentialsException.invalidPassword(this.name) - } - } - - /** - * Finds user by their id and returns the provider user instance - */ - protected async findById(id: string | number) { - const providerUser = await this.provider.findById(id) - if (!providerUser.user) { - throw InvalidCredentialsException.invalidUid(this.name) - } - - return providerUser - } - - /** - * Returns the provider user instance from the regular user details. Raises - * exception when id is missing - */ - protected async getUserForLogin( - user: any, - identifierKey: string - ): Promise> { - const providerUser = await this.provider.getUserFor(user) - - /** - * Ensure id exists on the user - */ - const id = providerUser.getId() - if (!id) { - throw new Exception(`Cannot login user. Value of "${identifierKey}" is not defined`) - } - - return providerUser - } - - /** - * Marks user as logged-in - */ - protected markUserAsLoggedIn(user: any, authenticated?: boolean, viaRemember?: boolean) { - this.user = user - this.isLoggedOut = false - authenticated && (this.isAuthenticated = true) - viaRemember && (this.viaRemember = true) - } - - /** - * Marks the user as logged out - */ - protected markUserAsLoggedOut() { - this.isLoggedOut = true - this.isAuthenticated = false - this.viaRemember = false - this.user = null - } - - /** - * Verifies user credentials - */ - public async verifyCredentials(uid: string, password: string): Promise { - if (!uid || !password) { - throw InvalidCredentialsException.invalidUid(this.name) - } - - const providerUser = await this.lookupUsingUid(uid) - await this.verifyPassword(providerUser, password) - return providerUser.user - } -} diff --git a/src/Guards/BasicAuth/index.ts b/src/Guards/BasicAuth/index.ts deleted file mode 100644 index fd6adf3..0000000 --- a/src/Guards/BasicAuth/index.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import { base64 } from '@poppinss/utils/build/helpers' -import { EmitterContract } from '@ioc:Adonis/Core/Event' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -import { - UserProviderContract, - BasicAuthGuardConfig, - BasicAuthGuardContract, - BasicAuthAuthenticateEventData, -} from '@ioc:Adonis/Addons/Auth' - -import { BaseGuard } from '../Base' -import { AuthenticationException } from '../../Exceptions/AuthenticationException' - -/** - * RegExp for basic auth credentials. - * Copy/pasted from https://github.com/jshttp/basic-auth/blob/master/index.js - * - * credentials = auth-scheme 1*SP token68 - * auth-scheme = "Basic" ; case insensitive - * token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"=" - */ -const CREDENTIALS_REGEXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/ - -/** - * RegExp for basic auth user/pass - * Copy/pasted from https://github.com/jshttp/basic-auth/blob/master/index.js - * - * user-pass = userid ":" password - * userid = * - * password = *TEXT - */ -const USER_PASS_REGEXP = /^([^:]*):(.*)$/ - -/** - * Basic auth guard enables user login using basic auth headers. - */ -export class BasicAuthGuard extends BaseGuard implements BasicAuthGuardContract { - constructor( - name: string, - config: BasicAuthGuardConfig, - private emitter: EmitterContract, - provider: UserProviderContract, - private ctx: HttpContextContract - ) { - super(name, config, provider) - } - - /** - * Returns data packet for the authenticate event. Arguments are - * - * - The mapping identifier - * - Logged in user - * - HTTP context - */ - private getAuthenticateEventData(user: any): BasicAuthAuthenticateEventData { - return { - name: this.name, - ctx: this.ctx, - user, - } - } - - /** - * Returns user credentials by parsing the HTTP "Authorization" header - */ - private getCredentials(): { uid: string; password: string } { - /** - * Ensure the "Authorization" header value exists - */ - const credentials = this.ctx.request.header('Authorization') - if (!credentials) { - throw AuthenticationException.invalidBasicCredentials(this.name) - } - - /** - * Ensure credentials are in correct format - */ - const match = CREDENTIALS_REGEXP.exec(credentials) - if (!match) { - throw AuthenticationException.invalidBasicCredentials(this.name) - } - - /** - * Ensure credentials are base64 encoded - */ - const decoded = base64.decode(match[1], 'utf-8', true) - - if (!decoded) { - throw AuthenticationException.invalidBasicCredentials(this.name) - } - - /** - * Ensure decoded credentials are in correct format - */ - const user = USER_PASS_REGEXP.exec(decoded) - if (!user) { - throw AuthenticationException.invalidBasicCredentials(this.name) - } - - return { uid: user[1], password: user[2] } - } - - /** - * Returns user for the uid and password. - */ - private async getUser(uid: string, password: string) { - try { - return await this.verifyCredentials(uid, password) - } catch { - throw AuthenticationException.invalidBasicCredentials(this.name) - } - } - - /** - * Implemented method to raise exception when someone calls this method - * without selecting the guard explicitly - */ - public async attempt(): Promise { - return this.login() - } - - /** - * Implemented method to raise exception when someone calls this method - * without selecting the guard explicitly - */ - public async loginViaId(): Promise { - return this.login() - } - - /** - * Implemented method to raise exception when someone calls this method - * without selecting the guard explicitly - */ - public async login(): Promise { - throw new Exception('There is no concept of login in basic auth', 500) - } - - /** - * Authenticates the current HTTP request by checking for the HTTP - * "Authorization" header - */ - public async authenticate(): Promise { - if (this.authenticationAttempted) { - return this.user - } - - this.authenticationAttempted = true - - /** - * Parse HTTP "Authorization" header to get credentials - */ - const credentials = this.getCredentials() - - /** - * Pull user from credentials - */ - const user = await this.getUser(credentials.uid, credentials.password) - - /** - * Mark user a logged in - */ - this.markUserAsLoggedIn(user, true) - - /** - * Emit event - */ - this.emitter.emit('adonis:basic:authenticate', this.getAuthenticateEventData(user)) - - return this.user - } - - /** - * Same as [[authenticate]] but returns a boolean over raising exceptions - */ - public async check(): Promise { - try { - await this.authenticate() - } catch (error) { - this.ctx.logger.trace(error, 'Authentication failure') - } - - return this.isAuthenticated - } - - /** - * Logout by clearing session and cookies - */ - public async logout() { - throw new Exception('There is no concept of logout in basic auth', 500) - } - - /** - * Serialize toJSON for JSON.stringify - */ - public toJSON() { - return { - isLoggedIn: this.isLoggedIn, - isGuest: this.isGuest, - authenticationAttempted: this.authenticationAttempted, - isAuthenticated: this.isAuthenticated, - user: this.user, - } - } -} diff --git a/src/Guards/Oat/index.ts b/src/Guards/Oat/index.ts deleted file mode 100644 index 2cf353f..0000000 --- a/src/Guards/Oat/index.ts +++ /dev/null @@ -1,449 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { DateTime } from 'luxon' -import { createHash } from 'crypto' -import { EmitterContract } from '@ioc:Adonis/Core/Event' -import { string, base64 } from '@poppinss/utils/build/helpers' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -import { - OATGuardConfig, - OATLoginOptions, - OATGuardContract, - OATLoginEventData, - OpaqueTokenContract, - UserProviderContract, - ProviderTokenContract, - TokenProviderContract, - OATAuthenticateEventData, -} from '@ioc:Adonis/Addons/Auth' - -import { BaseGuard } from '../Base' -import { OpaqueToken } from '../../Tokens/OpaqueToken' -import { ProviderToken } from '../../Tokens/ProviderToken' -import { AuthenticationException } from '../../Exceptions/AuthenticationException' - -/** - * Exposes the API to generate and authenticate HTTP request using - * opaque tokens - */ -export class OATGuard extends BaseGuard implements OATGuardContract { - constructor( - name: string, - public config: OATGuardConfig, - private emitter: EmitterContract, - provider: UserProviderContract, - private ctx: HttpContextContract, - public tokenProvider: TokenProviderContract - ) { - super(name, config, provider) - } - - /** - * Reference to the parsed token - */ - private parsedToken?: { - value: string - tokenId: string - } - - /** - * Length of the raw token. The hash length will vary - */ - private tokenLength = 60 - - /** - * Token type for the persistance store - */ - private tokenType = this.config.tokenProvider.type || 'opaque_token' - - /** - * Whether or not the authentication has been attempted - * for the current request - */ - public authenticationAttempted = false - - /** - * Find if the user has been logged out in the current request - */ - public isLoggedOut = false - - /** - * A boolean to know if user is retrieved by authenticating - * the current request or not - */ - public isAuthenticated = false - - /** - * Logged in or authenticated user - */ - public user?: any - - /** - * Token fetched as part of the authenticate or the login - * call - */ - public token?: ProviderTokenContract - - /** - * Accessor to know if user is logged in - */ - public get isLoggedIn() { - return !!this.user - } - - /** - * Accessor to know if user is a guest. It is always opposite - * of [[isLoggedIn]] - */ - public get isGuest() { - return !this.isLoggedIn - } - - /** - * Converts value to a sha256 hash - */ - private generateHash(token: string) { - return createHash('sha256').update(token).digest('hex') - } - - /** - * Converts expiry duration to an absolute date/time value - */ - private getExpiresAtDate(expiresIn?: string | number) { - if (!expiresIn) { - return - } - - const milliseconds = typeof expiresIn === 'string' ? string.toMs(expiresIn) : expiresIn - return DateTime.local().plus({ milliseconds }) - } - - /** - * Generates a new token + hash for the persistance - */ - private generateTokenForPersistance(expiresIn?: string | number) { - const token = string.generateRandom(this.tokenLength) - - return { - token, - hash: this.generateHash(token), - expiresAt: this.getExpiresAtDate(expiresIn), - } - } - - /** - * Returns data packet for the login event. Arguments are - * - * - The mapping identifier - * - Logged in user - * - HTTP context - * - API token - */ - private getLoginEventData(user: any, token: OpaqueTokenContract): OATLoginEventData { - return { - name: this.name, - ctx: this.ctx, - user, - token, - } - } - - /** - * Returns data packet for the authenticate event. Arguments are - * - * - The mapping identifier - * - Logged in user - * - HTTP context - * - A boolean to tell if logged in viaRemember or not - */ - private getAuthenticateEventData( - user: any, - token: ProviderTokenContract - ): OATAuthenticateEventData { - return { - name: this.name, - ctx: this.ctx, - user, - token, - } - } - - /** - * Parses the token received in the request. The method also performs - * some initial level of sanity checks. - */ - private parsePublicToken(token: string) { - const parts = token.split('.') - - /** - * Ensure the token has two parts - */ - if (parts.length !== 2) { - throw AuthenticationException.invalidToken(this.name) - } - - /** - * Ensure the first part is a base64 encode id - */ - const tokenId = base64.urlDecode(parts[0], undefined, true) - if (!tokenId) { - throw AuthenticationException.invalidToken(this.name) - } - - /** - * Ensure 2nd part of the token has the expected length - */ - if (parts[1].length !== this.tokenLength) { - throw AuthenticationException.invalidToken(this.name) - } - - /** - * Set parsed token - */ - this.parsedToken = { - tokenId, - value: parts[1], - } - - return this.parsedToken - } - - /** - * Returns the bearer token - */ - private getBearerToken(): string { - /** - * Ensure the "Authorization" header value exists - */ - const token = this.ctx.request.header('Authorization') - if (!token) { - throw AuthenticationException.invalidToken(this.name) - } - - /** - * Ensure that token has minimum of two parts and the first - * part is a constant string named `bearer` - */ - const [type, value] = token.split(' ') - if (!type || type.toLowerCase() !== 'bearer' || !value) { - throw AuthenticationException.invalidToken(this.name) - } - - return value - } - - /** - * Returns the token by reading it from the token provider - */ - private async getProviderToken(tokenId: string, value: string): Promise { - const providerToken = await this.tokenProvider.read( - tokenId, - this.generateHash(value), - this.tokenType - ) - if (!providerToken) { - throw AuthenticationException.invalidToken(this.name) - } - - return providerToken - } - - /** - * Returns user from the user session id - */ - private async getUserById(id: string | number) { - const authenticatable = await this.provider.findById(id) - if (!authenticatable.user) { - throw AuthenticationException.invalidToken(this.name) - } - - return authenticatable - } - - /** - * Verify user credentials and perform login - */ - public async attempt(uid: string, password: string, options?: OATLoginOptions): Promise { - const user = await this.verifyCredentials(uid, password) - return this.login(user, options) - } - - /** - * Login user using their id - */ - public async loginViaId(id: string | number, options?: OATLoginOptions): Promise { - const providerUser = await this.findById(id) - return this.login(providerUser.user, options) - } - - /** - * Generate token for a user. It is merely an alias for `login` - */ - public async generate(user: any, options?: OATLoginOptions) { - return this.login(user, options) - } - - /** - * Login a user - */ - public async login(user: any, options?: OATLoginOptions): Promise { - /** - * Normalize options with defaults - */ - const { expiresIn, name, ...meta } = Object.assign( - { - name: 'Opaque Access Token', - }, - options - ) - - /** - * Since the login method is not exposed to the end user, we cannot expect - * them to instantiate and pass an instance of provider user, so we - * create one manually. - */ - const providerUser = await this.getUserForLogin(user, this.config.provider.identifierKey) - - /** - * "getUserForLogin" raises exception when id is missing, so we can - * safely assume it is defined - */ - const id = providerUser.getId()! - const token = this.generateTokenForPersistance(expiresIn) - - /** - * Persist token to the database. Make sure that we are always - * passing the hash to the storage driver - */ - const providerToken = new ProviderToken(name, token.hash, id, this.tokenType) - providerToken.expiresAt = token.expiresAt - providerToken.meta = meta - const tokenId = await this.tokenProvider.write(providerToken) - - /** - * Construct a new API Token instance - */ - const apiToken = new OpaqueToken( - name, - `${base64.urlEncode(tokenId)}.${token.token}`, - providerUser.user - ) - apiToken.tokenHash = token.hash - apiToken.expiresAt = token.expiresAt - apiToken.meta = meta || {} - - /** - * Emit login event. It can be used to track user logins. - */ - this.emitter.emit('adonis:api:login', this.getLoginEventData(providerUser.user, apiToken)) - - /** - * Marking user as logged in - */ - this.markUserAsLoggedIn(providerUser.user) - this.token = providerToken - - return apiToken - } - - /** - * Authenticates the current HTTP request by checking for the bearer token - */ - public async authenticate(): Promise { - /** - * Return early when authentication has already attempted for - * the current request - */ - if (this.authenticationAttempted) { - return this.user - } - - this.authenticationAttempted = true - - /** - * Ensure the "Authorization" header value exists - */ - const token = this.getBearerToken() - const { tokenId, value } = this.parsePublicToken(token) - - /** - * Query token and user - */ - const providerToken = await this.getProviderToken(tokenId, value) - const providerUser = await this.getUserById(providerToken.userId) - - this.markUserAsLoggedIn(providerUser.user, true) - this.token = providerToken - this.emitter.emit( - 'adonis:api:authenticate', - this.getAuthenticateEventData(providerUser.user, this.token) - ) - return providerUser.user - } - - /** - * Same as [[authenticate]] but returns a boolean over raising exceptions - */ - public async check(): Promise { - try { - await this.authenticate() - } catch (error) { - /** - * Throw error when it is not an instance of the authentication - */ - if (error instanceof AuthenticationException === false) { - throw error - } - - this.ctx.logger.trace(error, 'Authentication failure') - } - - return this.isAuthenticated - } - - /** - * Alias for the logout method - */ - public async revoke() { - return this.logout() - } - - /** - * Logout by removing the token from the storage - */ - public async logout() { - if (!this.authenticationAttempted) { - await this.check() - } - - /** - * Clean up token from storage - */ - if (this.parsedToken) { - await this.tokenProvider.destroy(this.parsedToken.tokenId, this.tokenType) - } - - this.markUserAsLoggedOut() - } - - /** - * Serialize toJSON for JSON.stringify - */ - public toJSON() { - return { - isLoggedIn: this.isLoggedIn, - isGuest: this.isGuest, - authenticationAttempted: this.authenticationAttempted, - isAuthenticated: this.isAuthenticated, - user: this.user, - } - } -} diff --git a/src/Guards/Session/index.ts b/src/Guards/Session/index.ts deleted file mode 100644 index 0838bcb..0000000 --- a/src/Guards/Session/index.ts +++ /dev/null @@ -1,413 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import { string } from '@poppinss/utils/build/helpers' -import { EmitterContract } from '@ioc:Adonis/Core/Event' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -import { - UserProviderContract, - SessionGuardConfig, - SessionGuardContract, - ProviderUserContract, - SessionLoginEventData, - SessionAuthenticateEventData, -} from '@ioc:Adonis/Addons/Auth' - -import { BaseGuard } from '../Base' -import { AuthenticationException } from '../../Exceptions/AuthenticationException' - -/** - * Session guard enables user login using sessions. Also it allows for - * setting remember me tokens for life long login - */ -export class SessionGuard extends BaseGuard implements SessionGuardContract { - constructor( - name: string, - config: SessionGuardConfig, - private emitter: EmitterContract, - provider: UserProviderContract, - private ctx: HttpContextContract - ) { - super(name, config, provider) - } - - /** - * Number of years for the remember me token expiry - */ - private rememberMeTokenExpiry = '5y' - - /** - * The name of the session key name - */ - public get sessionKeyName() { - return `auth_${this.name}` - } - - /** - * The name of the session key name - */ - public get rememberMeKeyName() { - return `remember_${this.name}` - } - - /** - * Returns the session object from the context. - */ - private getSession() { - if (!this.ctx.session) { - throw new Exception('"@adonisjs/session" is required to use the "session" auth driver') - } - return this.ctx.session - } - - /** - * Set the user id inside the session. Also forces the session module - * to re-generate the session id - */ - private setSession(userId: string | number) { - this.getSession().put(this.sessionKeyName, userId) - this.getSession().regenerate() - } - - /** - * Generate remember me token - */ - private generateRememberMeToken(): string { - return string.generateRandom(20) - } - - /** - * Sets the remember me cookie with the remember me token - */ - private setRememberMeCookie(userId: string | number, token: string) { - const value = { - id: userId, - token: token, - } - - this.ctx.response.encryptedCookie(this.rememberMeKeyName, value, { - maxAge: this.rememberMeTokenExpiry, - httpOnly: true, - }) - } - - /** - * Clears the remember me cookie - */ - private clearRememberMeCookie() { - this.ctx.response.clearCookie(this.rememberMeKeyName) - } - - /** - * Clears user session and remember me cookie - */ - private clearUserFromStorage() { - this.getSession().forget(this.sessionKeyName) - this.clearRememberMeCookie() - } - - /** - * Returns data packet for the login event. Arguments are - * - * - The mapping identifier - * - Logged in user - * - HTTP context - * - Remember me token (optional) - */ - private getLoginEventData(user: any, token: string | null): SessionLoginEventData { - return { - name: this.name, - ctx: this.ctx, - user, - token, - } - } - - /** - * Returns data packet for the authenticate event. Arguments are - * - * - The mapping identifier - * - Logged in user - * - HTTP context - * - A boolean to tell if logged in viaRemember or not - */ - private getAuthenticateEventData( - user: any, - viaRemember: boolean - ): SessionAuthenticateEventData { - return { - name: this.name, - ctx: this.ctx, - user, - viaRemember, - } - } - - /** - * Returns the user id for the current HTTP request - */ - private getRequestSessionId() { - return this.getSession().get(this.sessionKeyName) - } - - /** - * Verifies the remember me token - */ - private verifyRememberMeToken( - rememberMeToken: any - ): asserts rememberMeToken is { id: string; token: string } { - if (!rememberMeToken || !rememberMeToken.id || !rememberMeToken.token) { - throw AuthenticationException.invalidSession(this.name) - } - } - - /** - * Returns user from the user session id - */ - private async getUserForSessionId(id: string | number) { - const authenticatable = await this.provider.findById(id) - if (!authenticatable.user) { - throw AuthenticationException.invalidSession(this.name) - } - - return authenticatable - } - - /** - * Returns user for the remember me token - */ - private async getUserForRememberMeToken(id: string, token: string) { - const authenticatable = await this.provider.findByRememberMeToken(id, token) - if (!authenticatable.user) { - throw AuthenticationException.invalidSession(this.name) - } - - return authenticatable - } - - /** - * Returns the remember me token of the user that is persisted - * inside the db. If not persisted, we create one and persist - * it - */ - private async getPersistedRememberMeToken( - providerUser: ProviderUserContract - ): Promise { - /** - * Create and persist the user remember me token, when an existing one is missing - */ - if (!providerUser.getRememberMeToken()) { - this.ctx.logger.trace('generating fresh remember me token') - providerUser.setRememberMeToken(this.generateRememberMeToken()) - await this.provider.updateRememberMeToken(providerUser) - } - - return providerUser.getRememberMeToken()! - } - - /** - * Verify user credentials and perform login - */ - public async attempt(uid: string, password: string, remember?: boolean): Promise { - const user = await this.verifyCredentials(uid, password) - await this.login(user, remember) - return user - } - - /** - * Login user using their id - */ - public async loginViaId(id: string | number, remember?: boolean): Promise { - const providerUser = await this.findById(id) - await this.login(providerUser.user, remember) - return providerUser.user - } - - /** - * Login a user - */ - public async login(user: any, remember?: boolean): Promise { - /** - * Since the login method is exposed to the end user, we cannot expect - * them to instantiate and return an instance of authenticatable, so - * we create one manually. - */ - const providerUser = await this.getUserForLogin(user, this.config.provider.identifierKey) - - /** - * getUserForLogin raises exception when id is missing, so we can - * safely assume it is defined - */ - const id = providerUser.getId()! - - /** - * Set session - */ - this.setSession(id) - - /** - * Set remember me token when enabled - */ - if (remember) { - const rememberMeToken = await this.getPersistedRememberMeToken(providerUser) - this.ctx.logger.trace('setting remember me cookie', { name: this.rememberMeKeyName }) - this.setRememberMeCookie(id, rememberMeToken) - } else { - /** - * Clear remember me cookie, which may have been set previously. - */ - this.clearRememberMeCookie() - } - - /** - * Emit login event. It can be used to track user logins and their devices. - */ - this.emitter.emit( - 'adonis:session:login', - this.getLoginEventData(providerUser.user, providerUser.getRememberMeToken()) - ) - - this.markUserAsLoggedIn(providerUser.user) - return providerUser.user - } - - /** - * Authenticates the current HTTP request by checking for the user - * session. - */ - public async authenticate(): Promise { - if (this.authenticationAttempted) { - return this.user - } - - this.authenticationAttempted = true - const sessionId = this.getRequestSessionId() - - /** - * If session id exists, then attempt to login the user using the - * session and return early - */ - if (sessionId) { - const providerUser = await this.getUserForSessionId(sessionId) - this.markUserAsLoggedIn(providerUser.user, true) - this.emitter.emit( - 'adonis:session:authenticate', - this.getAuthenticateEventData(providerUser.user, false) - ) - return this.user - } - - /** - * Otherwise look for remember me token. Raise exception, if both remember - * me token and session id are missing. - */ - const rememberMeToken = this.ctx.request.encryptedCookie(this.rememberMeKeyName) - if (!rememberMeToken) { - throw AuthenticationException.invalidSession(this.name) - } - - /** - * Ensure remember me token is valid after reading it from the cookie - */ - this.verifyRememberMeToken(rememberMeToken) - - /** - * Attempt to locate the user for remember me token - */ - const providerUser = await this.getUserForRememberMeToken( - rememberMeToken.id, - rememberMeToken.token - ) - this.setSession(providerUser.getId()!) - this.setRememberMeCookie(rememberMeToken.id, rememberMeToken.token) - - this.markUserAsLoggedIn(providerUser.user, true, true) - this.emitter.emit( - 'adonis:session:authenticate', - this.getAuthenticateEventData(providerUser.user, true) - ) - return this.user - } - - /** - * Same as [[authenticate]] but returns a boolean over raising exceptions - */ - public async check(): Promise { - try { - await this.authenticate() - } catch (error) { - /** - * Throw error when it is not an instance of the authentication - */ - if (error instanceof AuthenticationException === false) { - throw error - } - - this.ctx.logger.trace(error, 'Authentication failure') - } - - return this.isAuthenticated - } - - /** - * Logout by clearing session and cookies - */ - public async logout(recycleRememberToken?: boolean) { - /** - * Return early when not attempting to re-generate the remember me token - */ - if (!recycleRememberToken) { - this.clearUserFromStorage() - this.markUserAsLoggedOut() - return - } - - /** - * Attempt to authenticate the current request if not already authenticated. This - * will help us get an instance of the current user - */ - if (!this.authenticationAttempted) { - await this.check() - } - - /** - * If authentication passed, then re-generate the remember me token - * for the current user. - */ - if (this.user) { - const providerUser = await this.provider.getUserFor(this.user) - - this.ctx.logger.trace('re-generating remember me token') - providerUser.setRememberMeToken(this.generateRememberMeToken()) - await this.provider.updateRememberMeToken(providerUser) - } - - /** - * Logout user - */ - this.clearUserFromStorage() - this.markUserAsLoggedOut() - } - - /** - * Serialize toJSON for JSON.stringify - */ - public toJSON() { - return { - isLoggedIn: this.isLoggedIn, - isGuest: this.isGuest, - viaRemember: this.viaRemember, - authenticationAttempted: this.authenticationAttempted, - isAuthenticated: this.isAuthenticated, - user: this.user, - } - } -} diff --git a/src/TokenProviders/Database/index.ts b/src/TokenProviders/Database/index.ts deleted file mode 100644 index b570889..0000000 --- a/src/TokenProviders/Database/index.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { DateTime } from 'luxon' -import { safeEqual } from '@poppinss/utils/build/helpers' -import { DatabaseContract, QueryClientContract } from '@ioc:Adonis/Lucid/Database' -import { - TokenProviderContract, - ProviderTokenContract, - DatabaseTokenProviderConfig, -} from '@ioc:Adonis/Addons/Auth' - -import { ProviderToken } from '../../Tokens/ProviderToken' - -/** - * Database backend tokens provider - */ -export class TokenDatabaseProvider implements TokenProviderContract { - constructor(private config: DatabaseTokenProviderConfig, private db: DatabaseContract) {} - - /** - * Custom connection or query client - */ - private connection?: string | QueryClientContract - - /** - * Returns the query client for database queries - */ - private getQueryClient() { - if (!this.connection) { - return this.db.connection(this.config.connection) - } - - return typeof this.connection === 'string' - ? this.db.connection(this.connection) - : this.connection - } - - /** - * The foreign key column - */ - private foreignKey = this.config.foreignKey || 'user_id' - - /** - * Returns the builder query for a given token + type - */ - private getLookupQuery(tokenId: string, tokenType: string) { - return this.getQueryClient() - .from(this.config.table) - .where('id', tokenId) - .where('type', tokenType) - } - - /** - * Define custom connection - */ - public setConnection(connection: string | QueryClientContract): this { - this.connection = connection - return this - } - - /** - * Reads the token using the lookup token id - */ - public async read( - tokenId: string, - tokenHash: string, - tokenType: string - ): Promise { - const client = this.getQueryClient() - - /** - * Find token using id - */ - const tokenRow = await this.getLookupQuery(tokenId, tokenType).first() - if (!tokenRow || !tokenRow.token) { - return null - } - - /** - * Ensure hash of the user provided value is same as the one inside - * the database - */ - if (!safeEqual(tokenRow.token, tokenHash)) { - return null - } - - const { - name, - [this.foreignKey]: userId, - token: value, - expires_at: expiresAt, - type, - ...meta - } = tokenRow - let normalizedExpiryDate: undefined | DateTime - - /** - * Parse dialect date to an instance of Luxon - */ - if (expiresAt instanceof Date) { - normalizedExpiryDate = DateTime.fromJSDate(expiresAt) - } else if (expiresAt && typeof expiresAt === 'string') { - normalizedExpiryDate = DateTime.fromFormat(expiresAt, client.dialect.dateTimeFormat) - } else if (expiresAt && typeof expiresAt === 'number') { - normalizedExpiryDate = DateTime.fromMillis(expiresAt) - } - - /** - * Ensure token isn't expired - */ - if ( - normalizedExpiryDate && - normalizedExpiryDate.diff(DateTime.local(), 'milliseconds').milliseconds <= 0 - ) { - return null - } - - const token = new ProviderToken(name, value, userId, type) - token.expiresAt = expiresAt - token.meta = meta - return token - } - - /** - * Saves the token and returns the persisted token lookup id. - */ - public async write(token: ProviderToken): Promise { - const client = this.getQueryClient() - - /** - * Payload to save to the database - */ - const payload = { - [this.foreignKey]: token.userId, - name: token.name, - token: token.tokenHash, - type: token.type, - expires_at: token.expiresAt ? token.expiresAt.toFormat(client.dialect.dateTimeFormat) : null, - created_at: DateTime.local().toFormat(client.dialect.dateTimeFormat), - ...token.meta, - } - - const [row] = await client.table(this.config.table).insert(payload).returning('id') - - return String(typeof row === 'number' ? row : row.id) - } - - /** - * Removes a given token - */ - public async destroy(tokenId: string, tokenType: string) { - await this.getLookupQuery(tokenId, tokenType).delete() - } -} diff --git a/src/TokenProviders/Redis/index.ts b/src/TokenProviders/Redis/index.ts deleted file mode 100644 index 1f91377..0000000 --- a/src/TokenProviders/Redis/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import { safeEqual, cuid } from '@poppinss/utils/build/helpers' - -import { - RedisManagerContract, - RedisConnectionContract, - RedisClusterConnectionContract, -} from '@ioc:Adonis/Addons/Redis' - -import { - TokenProviderContract, - ProviderTokenContract, - RedisTokenProviderConfig, -} from '@ioc:Adonis/Addons/Auth' - -import { ProviderToken } from '../../Tokens/ProviderToken' - -/** - * Shape of the data persisted inside redis - */ -type PersistedToken = { - name: string - token: string -} & { - [key: string]: any -} - -/** - * Redis backed tokens provider. - */ -export class TokenRedisProvider implements TokenProviderContract { - constructor(private config: RedisTokenProviderConfig, private redis: RedisManagerContract) {} - - /** - * Custom connection or query client - */ - private connection?: string | RedisConnectionContract | RedisClusterConnectionContract - - /** - * Returns the singleton instance of the redis connection - */ - private getRedisConnection(): RedisConnectionContract | RedisClusterConnectionContract { - /** - * Use custom connection if defined - */ - if (this.connection) { - return typeof this.connection === 'string' - ? this.redis.connection(this.connection) - : this.connection - } - - /** - * Config must have a connection defined - */ - if (!this.config.redisConnection) { - throw new Exception( - 'Missing "redisConnection" property for auth redis provider inside "config/auth" file', - 500, - 'E_INVALID_AUTH_REDIS_CONFIG' - ) - } - - return this.redis.connection(this.config.redisConnection) - } - - /** - * The foreign key column - */ - private foreignKey = this.config.foreignKey || 'user_id' - - /** - * Parse the stringified redis token value to an object - */ - private parseToken(token: string | null): null | PersistedToken { - if (!token) { - return null - } - - try { - const tokenRow: any = JSON.parse(token) - if (!tokenRow.token || !tokenRow.name || !tokenRow[this.foreignKey]) { - return null - } - - return tokenRow - } catch { - return null - } - } - - /** - * Define custom connection - */ - public setConnection( - connection: string | RedisConnectionContract | RedisClusterConnectionContract - ): this { - this.connection = connection - return this - } - - /** - * Reads the token using the lookup token id - */ - public async read( - tokenId: string, - tokenHash: string, - tokenType: string - ): Promise { - /** - * Find token using id - */ - const tokenRow = this.parseToken(await this.getRedisConnection().get(`${tokenType}:${tokenId}`)) - if (!tokenRow) { - return null - } - - /** - * Ensure hash of the user provided value is same as the one inside - * the database - */ - if (!safeEqual(tokenRow.token, tokenHash)) { - return null - } - - const { name, [this.foreignKey]: userId, token: value, ...meta } = tokenRow - - const token = new ProviderToken(name, value, userId, tokenType) - token.meta = meta - return token - } - - /** - * Saves the token and returns the persisted token lookup id, which - * is a cuid. - */ - public async write(token: ProviderToken): Promise { - /** - * Payload to save to the database - */ - const payload: PersistedToken = { - [this.foreignKey]: token.userId, - name: token.name, - token: token.tokenHash, - ...token.meta, - } - - const ttl = token.expiresAt ? Math.ceil(token.expiresAt.diffNow('seconds').seconds) : 0 - const tokenId = cuid() - - if (token.expiresAt && ttl <= 0) { - throw new Exception( - 'The expiry date/time should be in the future', - 500, - 'E_INVALID_TOKEN_EXPIRY' - ) - } - - if (token.expiresAt) { - await this.getRedisConnection().setex( - `${token.type}:${tokenId}`, - ttl, - JSON.stringify(payload) - ) - } else { - await this.getRedisConnection().set(`${token.type}:${tokenId}`, JSON.stringify(payload)) - } - - return tokenId - } - - /** - * Removes a given token - */ - public async destroy(tokenId: string, tokenType: string) { - await this.getRedisConnection().del(`${tokenType}:${tokenId}`) - } -} diff --git a/src/Tokens/OpaqueToken/index.ts b/src/Tokens/OpaqueToken/index.ts deleted file mode 100644 index d1fc24a..0000000 --- a/src/Tokens/OpaqueToken/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { DateTime } from 'luxon' -import { OpaqueTokenContract } from '@ioc:Adonis/Addons/Auth' - -/** - * Opaque token represents a persisted token generated for a given user - * - * Calling `opaqueToken.toJSON()` will give you an object, that you can send back - * as response to share the token with the client. - */ -export class OpaqueToken implements OpaqueTokenContract { - /** - * The type of the token. Always set to bearer - */ - public type = 'bearer' as const - - /** - * The datetime in which the token will expire - */ - public expiresAt?: DateTime - - /** - * Time left until token gets expired - */ - public expiresIn?: number - - /** - * Any meta data attached to the token - */ - public meta: any - - /** - * Hash of the token saved inside the database. Make sure to never share - * this with the client - */ - public tokenHash: string - - constructor( - public name: string, // Name associated with the token - public token: string, // The raw token value. Only available for the first time - public user: any // The user for which the token is generated - ) {} - - /** - * Shareable version of the token - */ - public toJSON() { - return { - type: this.type, - token: this.token, - ...(this.expiresAt ? { expires_at: this.expiresAt.toISO() || undefined } : {}), - ...(this.expiresIn ? { expires_in: this.expiresIn } : {}), - } - } -} diff --git a/src/Tokens/ProviderToken/index.ts b/src/Tokens/ProviderToken/index.ts deleted file mode 100644 index 2c88850..0000000 --- a/src/Tokens/ProviderToken/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { DateTime } from 'luxon' -import { ProviderTokenContract } from '@ioc:Adonis/Addons/Auth' - -/** - * Token returned and accepted by the token providers - */ -export class ProviderToken implements ProviderTokenContract { - /** - * Expiry date - */ - public expiresAt?: DateTime - - /** - * All other token details - */ - public meta?: any - - constructor( - public name: string, // Name associated with the token - public tokenHash: string, // The hash to persist - public userId: string | number, // The user for which the token is generated - public type: string // The type of the token. - ) {} -} diff --git a/src/UserProviders/Database/User.ts b/src/UserProviders/Database/User.ts deleted file mode 100644 index 7bb27c8..0000000 --- a/src/UserProviders/Database/User.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import type { HashContract } from '@ioc:Adonis/Core/Hash' -import { inject } from '@adonisjs/core/build/standalone' -import type { - ProviderUserContract, - DatabaseProviderRow, - DatabaseProviderConfig, -} from '@ioc:Adonis/Addons/Auth' - -/** - * Database user works a bridge between the provider and the guard - */ -@inject([null, null, 'Adonis/Core/Hash']) -export class DatabaseUser implements ProviderUserContract { - constructor( - public user: DatabaseProviderRow | null, - private config: DatabaseProviderConfig, - private hash: HashContract - ) {} - - /** - * Returns the value of the user id - */ - public getId() { - return this.user ? this.user[this.config.identifierKey] : null - } - - /** - * Verifies the user password - */ - public async verifyPassword(plainPassword: string): Promise { - if (!this.user) { - throw new Exception('Cannot "verifyPassword" for non-existing user') - } - - /** - * Ensure user has password - */ - if (!this.user.password) { - throw new Exception('Auth user object must have a password in order to call "verifyPassword"') - } - - const hasher = this.config.hashDriver ? this.hash.use(this.config.hashDriver) : this.hash - return hasher.verify(this.user.password, plainPassword) - } - - /** - * Returns the user remember me token or null - */ - public getRememberMeToken() { - return this.user ? this.user.remember_me_token || null : null - } - - /** - * Updates user remember me token - */ - public setRememberMeToken(token: string) { - if (!this.user) { - throw new Exception('Cannot set "rememberMeToken" on non-existing user') - } - this.user.remember_me_token = token - } -} diff --git a/src/UserProviders/Database/index.ts b/src/UserProviders/Database/index.ts deleted file mode 100644 index c9deb9d..0000000 --- a/src/UserProviders/Database/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Hooks } from '@poppinss/hooks' -import { Exception, esmResolver } from '@poppinss/utils' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' -import { - DatabaseContract, - QueryClientContract, - DatabaseQueryBuilderContract, -} from '@ioc:Adonis/Lucid/Database' - -import { - DatabaseProviderRow, - ProviderUserContract, - DatabaseProviderConfig, - DatabaseProviderContract, -} from '@ioc:Adonis/Addons/Auth' - -import { DatabaseUser } from './User' - -/** - * Database provider to lookup users inside the database - */ -export class DatabaseProvider implements DatabaseProviderContract { - /** - * Hooks reference - */ - private hooks = new Hooks() - - /** - * Custom connection or query client - */ - private connection?: string | QueryClientContract - - /** - * Name of the remember_me_token column - */ - private rememberMeColumn = 'remember_me_token' - - constructor( - private application: ApplicationContract, - private config: DatabaseProviderConfig, - private db: DatabaseContract - ) {} - - /** - * Returns the query client for invoking queries - */ - private getQueryClient() { - if (!this.connection) { - return this.db.connection(this.config.connection) - } - - return typeof this.connection === 'string' - ? this.db.connection(this.connection) - : this.connection - } - - /** - * Returns the query builder instance for the users table - */ - private getUserQueryBuilder() { - return this.getQueryClient().from(this.config.usersTable) - } - - /** - * Ensure "user.id" is always present - */ - private ensureUserHasId(user: any): asserts user is DatabaseProviderRow { - /** - * Ignore when user is null - */ - if (!user) { - return - } - - if (!user[this.config.identifierKey]) { - throw new Exception( - `Auth database provider expects "${this.config.usersTable}.${this.config.identifierKey}" to always exist` - ) - } - } - - /** - * Executes the query to find the user, calls the registered hooks - * and wraps the result inside [[ProviderUserContract]] - */ - private async findUser(query: DatabaseQueryBuilderContract) { - await this.hooks.exec('before', 'findUser', query) - - const user = await query.first() - if (user) { - await this.hooks.exec('after', 'findUser', user) - } - - return this.getUserFor(user) - } - - /** - * Returns an instance of provider user - */ - public async getUserFor(user: any): Promise> { - this.ensureUserHasId(user) - const UserBuilder = this.config.user ? esmResolver(await this.config.user()) : DatabaseUser - return this.application.container.makeAsync(UserBuilder, [user, this.config]) - } - - /** - * Define custom connection - */ - public setConnection(connection: string | QueryClientContract): this { - this.connection = connection - return this - } - - /** - * Define before hooks. Check interface for exact type information - */ - public before(event: 'findUser', callback: (query: any) => Promise): this { - this.hooks.add('before', event, callback) - return this - } - - /** - * Define after hooks. Check interface for exact type information - */ - public after(event: 'findUser', callback: (...args: any[]) => Promise): this { - this.hooks.add('after', event, callback) - return this - } - - /** - * Returns the user row using the primary key - */ - public async findById(id: string | number) { - const query = this.getUserQueryBuilder() - return this.findUser(query.where(this.config.identifierKey, id)) - } - - /** - * Returns a user from their remember me token - */ - public async findByRememberMeToken(id: number | string, token: string) { - const query = this.getUserQueryBuilder() - .where(this.rememberMeColumn, token) - .where(this.config.identifierKey, id) - - return this.findUser(query) - } - - /** - * Returns the user row by searching the uidValue against - * their defined uids. - */ - public async findByUid(uidValue: string) { - const query = this.getUserQueryBuilder() - this.config.uids.forEach((uid) => query.orWhere(uid, uidValue)) - return this.findUser(query) - } - - /** - * Updates the user remember me token - */ - public async updateRememberMeToken(user: ProviderUserContract) { - this.ensureUserHasId(user) - - await this.getUserQueryBuilder() - .where(this.config.identifierKey, user[this.config.identifierKey]) - .update({ - remember_me_token: user.getRememberMeToken()!, - }) - } -} diff --git a/src/UserProviders/Lucid/User.ts b/src/UserProviders/Lucid/User.ts deleted file mode 100644 index 01c98ab..0000000 --- a/src/UserProviders/Lucid/User.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import { inject } from '@adonisjs/core/build/standalone' -import type { HashContract } from '@ioc:Adonis/Core/Hash' -import type { - LucidProviderModel, - ProviderUserContract, - LucidProviderConfig, -} from '@ioc:Adonis/Addons/Auth' - -/** - * Lucid works works a bridge between the provider and the guard - */ -@inject([null, null, 'Adonis/Core/Hash']) -export class LucidUser - implements ProviderUserContract> -{ - constructor( - public user: InstanceType | null, - private config: LucidProviderConfig, - private hash: HashContract - ) {} - - /** - * Returns the value of the user id - */ - public getId() { - return this.user ? this.user[this.config.identifierKey] : null - } - - /** - * Verifies the user password - */ - public async verifyPassword(plainPassword: string): Promise { - if (!this.user) { - throw new Exception('Cannot "verifyPassword" for non-existing user') - } - - /** - * Ensure user has password - */ - if (!this.user.password) { - throw new Exception('Auth user object must have a password in order to call "verifyPassword"') - } - - const hasher = this.config.hashDriver ? this.hash.use(this.config.hashDriver) : this.hash - return hasher.verify(this.user!.password, plainPassword) - } - - /** - * Returns the user remember me token or null - */ - public getRememberMeToken() { - return this.user ? this.user.rememberMeToken || null : null - } - - /** - * Updates user remember me token - */ - public setRememberMeToken(token: string) { - if (!this.user) { - throw new Exception('Cannot set "rememberMeToken" on non-existing user') - } - this.user.rememberMeToken = token - } -} diff --git a/src/UserProviders/Lucid/index.ts b/src/UserProviders/Lucid/index.ts deleted file mode 100644 index 3f73d50..0000000 --- a/src/UserProviders/Lucid/index.ts +++ /dev/null @@ -1,183 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Hooks } from '@poppinss/hooks' -import { esmResolver } from '@poppinss/utils' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' -import { ModelQueryBuilderContract } from '@ioc:Adonis/Lucid/Orm' -import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' -import { - LucidProviderModel, - LucidProviderConfig, - ProviderUserContract, - LucidProviderContract, -} from '@ioc:Adonis/Addons/Auth' - -import { LucidUser } from './User' - -/** - * Lucid provider uses Lucid models to lookup a users - */ -export class LucidProvider implements LucidProviderContract { - /** - * Hooks reference - */ - private hooks = new Hooks() - - /** - * Custom connection or query client - */ - private connection?: string | QueryClientContract - - constructor( - private application: ApplicationContract, - private config: LucidProviderConfig - ) {} - - /** - * The models options for constructing a query - */ - private getModelOptions() { - if (typeof this.connection === 'string') { - return { connection: this.connection } - } - - if (this.connection) { - return { client: this.connection } - } - - return this.config.connection ? { connection: this.config.connection } : {} - } - - /** - * Returns the auth model - */ - private async getModel(): Promise { - const model = await this.config.model() - return esmResolver(model) - } - - /** - * Returns query instance for the user model - */ - private async getModelQuery(model?: LucidProviderModel) { - model = model || (await this.getModel()) - return { - query: model.query(this.getModelOptions()), - } - } - - /** - * Executes the query to find the user, calls the registered hooks - * and wraps the result inside [[ProviderUserContract]] - */ - private async findUser(query: ModelQueryBuilderContract) { - await this.hooks.exec('before', 'findUser', query) - - const user = await query.first() - if (user) { - await this.hooks.exec('after', 'findUser', user) - } - - return this.getUserFor(user) - } - - /** - * Returns an instance of the [[ProviderUser]] by wrapping lucid model - * inside it - */ - public async getUserFor(user: InstanceType | null) { - const UserBuilder = this.config.user ? esmResolver(await this.config.user()) : LucidUser - return this.application.container.makeAsync(UserBuilder, [user, this.config]) - } - - /** - * Define custom connection - */ - public setConnection(connection: string | QueryClientContract): this { - this.connection = connection - return this - } - - /** - * Define before hooks. Check interface for exact type information - */ - public before(event: 'findUser', callback: (query: any) => Promise): this { - this.hooks.add('before', event, callback) - return this - } - - /** - * Define after hooks. Check interface for exact type information - */ - public after(event: 'findUser', callback: (...args: any[]) => Promise): this { - this.hooks.add('after', event, callback) - return this - } - - /** - * Returns a user instance using the primary key value - */ - public async findById(id: string | number) { - const { query } = await this.getModelQuery() - return this.findUser(query.where(this.config.identifierKey, id)) - } - - /** - * Returns a user instance using a specific token type and value - */ - public async findByRememberMeToken(id: string | number, value: string) { - const { query } = await this.getModelQuery() - return this.findUser(query.where(this.config.identifierKey, id).where('rememberMeToken', value)) - } - - /** - * Returns the user instance by searching the uidValue against - * their defined uids. - */ - public async findByUid(uidValue: string) { - const model = await this.getModel() - - /** - * Use custom function on the model. This time, we do not emit - * an event, since the user custom lookup may not even - * run a query at all. - */ - if (typeof model.findForAuth === 'function') { - const user = await model.findForAuth(this.config.uids, uidValue) - return this.getUserFor(user) - } - - /** - * Lookup by running a custom query. - */ - const { query } = await this.getModelQuery() - this.config.uids.forEach((uid) => query.orWhere(uid, uidValue)) - return this.findUser(query) - } - - /** - * Updates the user remember me token. The guard must called `setRememberMeToken` - * before invoking this method. - */ - public async updateRememberMeToken( - providerUser: ProviderUserContract> - ) { - /** - * Extra check to find malformed guards - */ - if (!providerUser.user!.$dirty.rememberMeToken) { - throw new Error( - 'The guard must called "setRememberMeToken" before calling "updateRememberMeToken" on the Lucid provider' - ) - } - - await providerUser.user!.save() - } -} diff --git a/standalone.ts b/standalone.ts deleted file mode 100644 index 57ecd98..0000000 --- a/standalone.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export { AuthenticationException } from './src/Exceptions/AuthenticationException' diff --git a/templates/config/auth.txt b/templates/config/auth.txt deleted file mode 100644 index dcc7727..0000000 --- a/templates/config/auth.txt +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Config source: https://git.io/JY0mp - * - * Feel free to let us know via PR, if you find something broken in this config - * file. - */ - -import type { AuthConfig } from '@ioc:Adonis/Addons/Auth' - -/* -|-------------------------------------------------------------------------- -| Authentication Mapping -|-------------------------------------------------------------------------- -| -| List of available authentication mapping. You must first define them -| inside the `contracts/auth.ts` file before mentioning them here. -| -*/ -const authConfig: AuthConfig = { - guard: '{{ guards.0 }}', - guards: { -{{#hasGuard.web}} -{{> web_guard}} -{{/hasGuard.web}} -{{#hasGuard.api}} -{{> api_guard}} -{{/hasGuard.api}} -{{#hasGuard.basic}} -{{> basic_guard}} -{{/hasGuard.basic}} - }, -} - -export default authConfig diff --git a/templates/config/partials/api-guard.txt b/templates/config/partials/api-guard.txt deleted file mode 100644 index fdcac14..0000000 --- a/templates/config/partials/api-guard.txt +++ /dev/null @@ -1,22 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | OAT Guard - |-------------------------------------------------------------------------- - | - | OAT (Opaque access tokens) guard uses database backed tokens to authenticate - | HTTP request. This guard DOES NOT rely on sessions or cookies and uses - | Authorization header value for authentication. - | - | Use this guard to authenticate mobile apps or web clients that cannot rely - | on cookies/sessions. - | - */ - api: { - driver: 'oat', - - {{> token_provider}} - - provider: { - {{> provider}} - }, - }, diff --git a/templates/config/partials/basic-guard.txt b/templates/config/partials/basic-guard.txt deleted file mode 100644 index 796143e..0000000 --- a/templates/config/partials/basic-guard.txt +++ /dev/null @@ -1,19 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | Basic Auth Guard - |-------------------------------------------------------------------------- - | - | Uses Basic auth to authenticate an HTTP request. There is no concept of - | "login" and "logout" with basic auth. You just authenticate the requests - | using a middleware and browser will prompt the user to enter their login - | details - | - */ - basic: { - driver: 'basic', - realm: 'Login', - - provider: { - {{> provider}} - }, - }, diff --git a/templates/config/partials/tokens-provider-database.txt b/templates/config/partials/tokens-provider-database.txt deleted file mode 100644 index 66c2541..0000000 --- a/templates/config/partials/tokens-provider-database.txt +++ /dev/null @@ -1,19 +0,0 @@ -/* -|-------------------------------------------------------------------------- -| Tokens provider -|-------------------------------------------------------------------------- -| -| Uses SQL database for managing tokens. Use the "database" driver, when -| tokens are the secondary mode of authentication. -| For example: The Github personal tokens -| -| The foreignKey column is used to make the relationship between the user -| and the token. You are free to use any column name here. -| -*/ -tokenProvider: { - type: 'api', - driver: 'database', - table: 'api_tokens', - foreignKey: 'user_id', -}, diff --git a/templates/config/partials/tokens-provider-redis.txt b/templates/config/partials/tokens-provider-redis.txt deleted file mode 100644 index 8c19d88..0000000 --- a/templates/config/partials/tokens-provider-redis.txt +++ /dev/null @@ -1,22 +0,0 @@ -/* -|-------------------------------------------------------------------------- -| Redis provider for managing tokens -|-------------------------------------------------------------------------- -| -| Uses Redis for managing tokens. We recommend using the "redis" driver -| over the "database" driver when the tokens based auth is the -| primary authentication mode. -| -| Redis ensure that all the expired tokens gets cleaned up automatically. -| Whereas with SQL, you have to cleanup expired tokens manually. -| -| The foreignKey column is used to make the relationship between the user -| and the token. You are free to use any column name here. -| -*/ -tokenProvider: { - type: 'api', - driver: 'redis', - redisConnection: 'local', - foreignKey: 'user_id', -}, diff --git a/templates/config/partials/user-provider-database.txt b/templates/config/partials/user-provider-database.txt deleted file mode 100644 index f9b44a5..0000000 --- a/templates/config/partials/user-provider-database.txt +++ /dev/null @@ -1,43 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | Driver - |-------------------------------------------------------------------------- - | - | Name of the driver - | - */ - driver: 'database', - - /* - |-------------------------------------------------------------------------- - | Identifier key - |-------------------------------------------------------------------------- - | - | The identifier key is the unique key inside the defined database table. - | In most cases specifying the primary key is the right choice. - | - */ - identifierKey: 'id', - - /* - |-------------------------------------------------------------------------- - | Uids - |-------------------------------------------------------------------------- - | - | Uids are used to search a user against one of the mentioned columns. During - | login, the auth module will search the user mentioned value against one - | of the mentioned columns to find their user record. - | - */ - uids: ['email'], - - /* - |-------------------------------------------------------------------------- - | Database table - |-------------------------------------------------------------------------- - | - | The database table to query. Make sure the database table has a `password` - | field and `remember_me_token` column. - | - */ - usersTable: '{{ usersTableName }}', diff --git a/templates/config/partials/user-provider-lucid.txt b/templates/config/partials/user-provider-lucid.txt deleted file mode 100644 index 694c1fb..0000000 --- a/templates/config/partials/user-provider-lucid.txt +++ /dev/null @@ -1,45 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | Driver - |-------------------------------------------------------------------------- - | - | Name of the driver - | - */ - driver: 'lucid', - - /* - |-------------------------------------------------------------------------- - | Identifier key - |-------------------------------------------------------------------------- - | - | The identifier key is the unique key on the model. In most cases specifying - | the primary key is the right choice. - | - */ - identifierKey: 'id', - - /* - |-------------------------------------------------------------------------- - | Uids - |-------------------------------------------------------------------------- - | - | Uids are used to search a user against one of the mentioned columns. During - | login, the auth module will search the user mentioned value against one - | of the mentioned columns to find their user record. - | - */ - uids: ['email'], - - /* - |-------------------------------------------------------------------------- - | Model - |-------------------------------------------------------------------------- - | - | The model to use for fetching or finding users. The model is imported - | lazily since the config files are read way earlier in the lifecycle - | of booting the app and the models may not be in a usable state at - | that time. - | - */ - model: () => import('{{{ modelNamespace }}}'), diff --git a/templates/config/partials/web-guard.txt b/templates/config/partials/web-guard.txt deleted file mode 100644 index e6ca82f..0000000 --- a/templates/config/partials/web-guard.txt +++ /dev/null @@ -1,17 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | Web Guard - |-------------------------------------------------------------------------- - | - | Web guard uses classic old school sessions for authenticating users. - | If you are building a standard web application, it is recommended to - | use web guard with session driver - | - */ - web: { - driver: 'session', - - provider: { - {{> provider}} - }, - }, diff --git a/templates/contract/auth.txt b/templates/contract/auth.txt deleted file mode 100644 index 1d9703f..0000000 --- a/templates/contract/auth.txt +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Contract source: https://git.io/JOdz5 - * - * Feel free to let us know via PR, if you find something broken in this - * file. - */ -{{#modelNamespace}} - -import {{ modelName }} from '{{{ modelNamespace }}}' -{{/modelNamespace}} - -declare module '@ioc:Adonis/Addons/Auth' { - /* - |-------------------------------------------------------------------------- - | Providers - |-------------------------------------------------------------------------- - | - | The providers are used to fetch users. The Auth module comes pre-bundled - | with two providers that are `Lucid` and `Database`. Both uses database - | to fetch user details. - | - | You can also create and register your own custom providers. - | - */ - interface ProvidersList { -{{> provider}} - } - - /* - |-------------------------------------------------------------------------- - | Guards - |-------------------------------------------------------------------------- - | - | The guards are used for authenticating users using different drivers. - | The auth module comes with 3 different guards. - | - | - SessionGuardContract - | - BasicAuthGuardContract - | - OATGuardContract ( Opaque access token ) - | - | Every guard needs a provider for looking up users from the database. - | - */ - interface GuardsList { -{{#hasGuard.web}} -{{> web_guard}} -{{/hasGuard.web}} -{{#hasGuard.api}} -{{> api_guard}} -{{/hasGuard.api}} -{{#hasGuard.basic}} -{{> basic_guard}} -{{/hasGuard.basic}} - } -} diff --git a/templates/contract/partials/api-guard.txt b/templates/contract/partials/api-guard.txt deleted file mode 100644 index b4a1fa7..0000000 --- a/templates/contract/partials/api-guard.txt +++ /dev/null @@ -1,14 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | OAT Guard - |-------------------------------------------------------------------------- - | - | OAT, stands for (Opaque access tokens) guard uses database backed tokens - | to authenticate requests. - | - */ - api: { - implementation: OATGuardContract<'user', 'api'> - config: OATGuardConfig<'user'> - client: OATClientContract<'user'> - } diff --git a/templates/contract/partials/basic-guard.txt b/templates/contract/partials/basic-guard.txt deleted file mode 100644 index 0dc2120..0000000 --- a/templates/contract/partials/basic-guard.txt +++ /dev/null @@ -1,14 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | Basic Auth Guard - |-------------------------------------------------------------------------- - | - | The basic guard uses basic auth for maintaining user login state. It uses - | the `user` provider for fetching user details. - | - */ - basic: { - implementation: BasicAuthGuardContract<'user', 'basic'> - config: BasicAuthGuardConfig<'user'> - client: BasicAuthClientContract<'user'> - } diff --git a/templates/contract/partials/user-provider-database.txt b/templates/contract/partials/user-provider-database.txt deleted file mode 100644 index 5e94224..0000000 --- a/templates/contract/partials/user-provider-database.txt +++ /dev/null @@ -1,16 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | User Provider - |-------------------------------------------------------------------------- - | - | The following provider directlly uses Database query builder for fetching - | user details from the database for authentication. - | - | You can create multiple providers using the same underlying driver with - | different database tables. - | - */ - user: { - implementation: DatabaseProviderContract - config: DatabaseProviderConfig - } diff --git a/templates/contract/partials/user-provider-lucid.txt b/templates/contract/partials/user-provider-lucid.txt deleted file mode 100644 index 221b6cf..0000000 --- a/templates/contract/partials/user-provider-lucid.txt +++ /dev/null @@ -1,16 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | User Provider - |-------------------------------------------------------------------------- - | - | The following provider uses Lucid models as a driver for fetching user - | details from the database for authentication. - | - | You can create multiple providers using the same underlying driver with - | different Lucid models. - | - */ - user: { - implementation: LucidProviderContract - config: LucidProviderConfig - } diff --git a/templates/contract/partials/web-guard.txt b/templates/contract/partials/web-guard.txt deleted file mode 100644 index 94a681d..0000000 --- a/templates/contract/partials/web-guard.txt +++ /dev/null @@ -1,14 +0,0 @@ - /* - |-------------------------------------------------------------------------- - | Web Guard - |-------------------------------------------------------------------------- - | - | The web guard uses sessions for maintaining user login state. It uses - | the `user` provider for fetching user details. - | - */ - web: { - implementation: SessionGuardContract<'user', 'web'> - config: SessionGuardConfig<'user'> - client: SessionClientContract<'user'> - } diff --git a/templates/middleware/Auth.txt b/templates/middleware/Auth.txt deleted file mode 100644 index 1a26ad0..0000000 --- a/templates/middleware/Auth.txt +++ /dev/null @@ -1,76 +0,0 @@ -import { AuthenticationException } from '@adonisjs/auth/build/standalone' -import type { GuardsList } from '@ioc:Adonis/Addons/Auth' -import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -/** - * Auth middleware is meant to restrict un-authenticated access to a given route - * or a group of routes. - * - * You must register this middleware inside `start/kernel.ts` file under the list - * of named middleware. - */ -export default class AuthMiddleware { - /** - * The URL to redirect to when request is Unauthorized - */ - protected redirectTo = '/login' - - /** - * Authenticates the current HTTP request against a custom set of defined - * guards. - * - * The authentication loop stops as soon as the user is authenticated using any - * of the mentioned guards and that guard will be used by the rest of the code - * during the current request. - */ - protected async authenticate(auth: HttpContextContract['auth'], guards: (keyof GuardsList)[]) { - /** - * Hold reference to the guard last attempted within the for loop. We pass - * the reference of the guard to the "AuthenticationException", so that - * it can decide the correct response behavior based upon the guard - * driver - */ - let guardLastAttempted: string | undefined - - for (let guard of guards) { - guardLastAttempted = guard - - if (await auth.use(guard).check()) { - /** - * Instruct auth to use the given guard as the default guard for - * the rest of the request, since the user authenticated - * succeeded here - */ - auth.defaultGuard = guard - return true - } - } - - /** - * Unable to authenticate using any guard - */ - throw new AuthenticationException( - 'Unauthorized access', - 'E_UNAUTHORIZED_ACCESS', - guardLastAttempted, - this.redirectTo, - ) - } - - /** - * Handle request - */ - public async handle ( - { auth }: HttpContextContract, - next: () => Promise, - customGuards: (keyof GuardsList)[] - ) { - /** - * Uses the user defined guards or the default guard mentioned in - * the config file - */ - const guards = customGuards.length ? customGuards : [auth.name] - await this.authenticate(auth, guards) - await next() - } -} diff --git a/templates/middleware/SilentAuth.txt b/templates/middleware/SilentAuth.txt deleted file mode 100644 index 5d3ac8f..0000000 --- a/templates/middleware/SilentAuth.txt +++ /dev/null @@ -1,21 +0,0 @@ -import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -/** - * Silent auth middleware can be used as a global middleware to silent check - * if the user is logged-in or not. - * - * The request continues as usual, even when the user is not logged-in. - */ -export default class SilentAuthMiddleware { - /** - * Handle request - */ - public async handle({ auth }: HttpContextContract, next: () => Promise) { - /** - * Check if user is logged-in or not. If yes, then `ctx.auth.user` will be - * set to the instance of the currently logged in user. - */ - await auth.check() - await next() - } -} diff --git a/templates/migrations/api_tokens.txt b/templates/migrations/api_tokens.txt deleted file mode 100644 index 7c62973..0000000 --- a/templates/migrations/api_tokens.txt +++ /dev/null @@ -1,25 +0,0 @@ -import BaseSchema from '@ioc:Adonis/Lucid/Schema' - -export default class extends BaseSchema { - protected tableName = '{{ tokensTableName }}' - - public async up() { - this.schema.createTable(this.tableName, (table) => { - table.increments('id').primary() - table.integer('user_id').unsigned().references('id').inTable('{{ usersTableName }}').onDelete('CASCADE') - table.string('name').notNullable() - table.string('type').notNullable() - table.string('token', 64).notNullable().unique() - - /** - * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL - */ - table.timestamp('expires_at', { useTz: true }).nullable() - table.timestamp('created_at', { useTz: true }).notNullable() - }) - } - - public async down() { - this.schema.dropTable(this.tableName) - } -} diff --git a/templates/migrations/auth.txt b/templates/migrations/auth.txt deleted file mode 100644 index 005627e..0000000 --- a/templates/migrations/auth.txt +++ /dev/null @@ -1,24 +0,0 @@ -import BaseSchema from '@ioc:Adonis/Lucid/Schema' - -export default class extends BaseSchema { - protected tableName = '{{ usersTableName }}' - - public async up() { - this.schema.createTable(this.tableName, (table) => { - table.increments('id').primary() - table.string('email', 255).notNullable().unique() - table.string('password', 180).notNullable() - table.string('remember_me_token').nullable() - - /** - * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL - */ - table.timestamp('created_at', { useTz: true }).notNullable() - table.timestamp('updated_at', { useTz: true }).notNullable() - }) - } - - public async down() { - this.schema.dropTable(this.tableName) - } -} diff --git a/templates/model.txt b/templates/model.txt deleted file mode 100644 index 37bd79b..0000000 --- a/templates/model.txt +++ /dev/null @@ -1,30 +0,0 @@ -import { DateTime } from 'luxon' -import Hash from '@ioc:Adonis/Core/Hash' -import { column, beforeSave, BaseModel } from '@ioc:Adonis/Lucid/Orm' - -export default class {{ modelName }} extends BaseModel { - @column({ isPrimary: true }) - public id: number - - @column() - public email: string - - @column({ serializeAs: null }) - public password: string - - @column() - public rememberMeToken: string | null - - @column.dateTime({ autoCreate: true }) - public createdAt: DateTime - - @column.dateTime({ autoCreate: true, autoUpdate: true }) - public updatedAt: DateTime - - @beforeSave() - public static async hashPassword ({{ modelReference }}: {{ modelName }}) { - if ({{ modelReference }}.$dirty.password) { - {{ modelReference }}.password = await Hash.make({{ modelReference }}.password) - } - } -} diff --git a/test-helpers/contracts.ts b/test-helpers/contracts.ts deleted file mode 100644 index 4e7b83d..0000000 --- a/test-helpers/contracts.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { User } from '../example/models' - -declare module '@ioc:Adonis/Addons/Auth' { - interface ProvidersList { - lucid: { - implementation: LucidProviderContract - config: LucidProviderConfig - } - database: { - config: DatabaseProviderConfig - implementation: DatabaseProviderContract - } - } - - interface GuardsList { - session: { - implementation: SessionGuardContract<'lucid', 'session'> - config: SessionGuardConfig<'lucid'> - client: SessionClientContract<'lucid'> - } - api: { - implementation: OATGuardContract<'lucid', 'api'> - config: OATGuardConfig<'lucid'> - client: OATClientContract<'lucid'> - } - apiDb: { - implementation: OATGuardContract<'database', 'apiDb'> - config: OATGuardConfig<'database'> - client: OATClientContract<'database'> - } - sessionDb: { - implementation: SessionGuardContract<'database', 'sessionDb'> - config: SessionGuardConfig<'database'> - client: SessionClientContract<'database'> - } - basic: { - implementation: BasicAuthGuardContract<'lucid', 'basic'> - config: BasicAuthGuardConfig<'lucid'> - client: BasicAuthClientContract<'lucid'> - } - } -} - -declare module '@ioc:Adonis/Core/Hash' { - interface HashersList { - bcrypt: HashDrivers['bcrypt'] - } -} diff --git a/test-helpers/index.ts b/test-helpers/index.ts deleted file mode 100644 index 59f08bc..0000000 --- a/test-helpers/index.ts +++ /dev/null @@ -1,524 +0,0 @@ -/* - * @adonis-auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import 'reflect-metadata' -import { join, sep, posix } from 'path' -import { MarkOptional } from 'ts-essentials' -import { Filesystem } from '@poppinss/dev-utils' -import { LucidModel } from '@ioc:Adonis/Lucid/Orm' -import { Application } from '@adonisjs/core/build/standalone' -import { RedisManagerContract } from '@ioc:Adonis/Addons/Redis' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' -import { DatabaseContract, QueryClientContract } from '@ioc:Adonis/Lucid/Database' - -import { OATGuard } from '../src/Guards/Oat' -import { SessionGuard } from '../src/Guards/Session' -import { BasicAuthGuard } from '../src/Guards/BasicAuth' -import { LucidProvider } from '../src/UserProviders/Lucid' -import { DatabaseProvider } from '../src/UserProviders/Database' -import { TokenRedisProvider } from '../src/TokenProviders/Redis' -import { TokenDatabaseProvider } from '../src/TokenProviders/Database' - -import { - LucidProviderModel, - LucidProviderConfig, - LucidProviderContract, - TokenProviderContract, - DatabaseProviderConfig, - DatabaseProviderContract, - DatabaseTokenProviderConfig, -} from '@ioc:Adonis/Addons/Auth' - -import { OATClient } from '../src/Clients/Oat' -import { SessionClient } from '../src/Clients/Session' - -export const fs = new Filesystem(join(__dirname, '__app')) - -/** - * Setup application - */ -export async function setupApplication( - additionalProviders?: string[], - environment: 'web' | 'repl' | 'test' = 'test', - sessionDriver = 'cookie' -) { - await fs.add('.env', '') - await fs.add( - 'config/app.ts', - ` - export const appKey = 'averylong32charsrandomsecretkey', - export const http = { - cookie: {}, - trustProxy: () => true, - } - ` - ) - - await fs.add( - 'config/hash.ts', - ` - const hashConfig = { - default: 'bcrypt' as const, - list: { - bcrypt: { - driver: 'bcrypt', - rounds: 10, - }, - }, - } - - export default hashConfig - ` - ) - - await fs.add( - 'config/session.ts', - ` - const sessionConfig = { - driver: ${sessionDriver ? `'${sessionDriver}'` : 'cookie'}, - cookieName: 'adonis-session', - clearWithBrowser: false, - age: '2h', - cookie: { - path: '/', - }, - } - - export default sessionConfig - ` - ) - - await fs.add( - 'config/database.ts', - `const databaseConfig = { - connection: 'primary', - connections: { - primary: { - client: 'sqlite3', - connection: { - filename: '${join(fs.basePath, 'primary.sqlite3').split(sep).join(posix.sep)}', - }, - }, - secondary: { - client: 'sqlite3', - connection: { - filename: '${join(fs.basePath, 'secondary.sqlite3').split(sep).join(posix.sep)}', - }, - }, - } - } - - export default databaseConfig` - ) - - await fs.add( - 'config/auth.ts', - `const authConfig = { - guard: 'web', - guards: { - web: { - driver: 'session', - provider: { - driver: 'database', - usersTable: 'users', - identifierKey: 'id' - }, - }, - apiDb: { - driver: 'oat', - provider: { - driver: 'database', - usersTable: 'users', - identifierKey: 'id' - }, - tokenProvider: { - driver: 'database', - table: 'api_tokens' - } - } - } - } - - export default authConfig` - ) - - await fs.add( - 'config/redis.ts', - `const redisConfig = { - connection: 'local', - connections: { - local: {}, - localDb1: { - db: '2' - } - } - } - export default redisConfig` - ) - - const app = new Application(fs.basePath, environment, { - aliases: { - App: './app', - }, - providers: ['@adonisjs/core', '@adonisjs/repl', '@adonisjs/lucid', '@adonisjs/redis'] - .concat(additionalProviders || []) - .concat(['@adonisjs/session']), - }) - - await app.setup() - await app.registerProviders() - await app.bootProviders() - - return app -} - -/** - * Create the users tables - */ -async function createUsersTable(client: QueryClientContract) { - await client.schema.createTable('users', (table) => { - table.increments('id').notNullable().primary() - table.string('username').notNullable().unique() - table.string('email').notNullable().unique() - table.string('password') - table.string('remember_me_token').nullable() - table.boolean('is_active').notNullable().defaultTo(1) - table.string('country').notNullable().defaultTo('IN') - }) -} - -/** - * Create the api tokens tables - */ -async function createTokensTable(client: QueryClientContract) { - await client.schema.createTable('api_tokens', (table) => { - table.increments('id').notNullable().primary() - table.integer('user_id').notNullable().unsigned() - table.string('name').notNullable() - table.string('type').notNullable() - table.string('token').notNullable() - table.timestamp('expires_at', { useTz: true }).nullable() - table.string('ip_address').nullable() - table.string('device_name').nullable() - table.timestamps(true) - }) -} - -/** - * Returns default config for the lucid provider - */ -export function getLucidProviderConfig( - config: MarkOptional, 'driver' | 'uids' | 'identifierKey' | 'user'> -) { - const defaults: LucidProviderConfig = { - driver: 'lucid' as const, - uids: ['username', 'email' as any], - model: config.model, - identifierKey: 'id', - } - return defaults -} - -/** - * Returns default config for the database provider - */ -export function getDatabaseProviderConfig() { - const defaults: DatabaseProviderConfig = { - driver: 'database' as const, - uids: ['username', 'email'], - identifierKey: 'id', - usersTable: 'users', - } - return defaults -} - -/** - * Performs an initial setup - */ -export async function setup(application: ApplicationContract) { - const db = application.container.use('Adonis/Lucid/Database') - await createUsersTable(db.connection()) - await createUsersTable(db.connection('secondary')) - await createTokensTable(db.connection()) - await createTokensTable(db.connection('secondary')) -} - -/** - * Performs cleanup - */ -export async function cleanup(application: ApplicationContract) { - const db = application.container.use('Adonis/Lucid/Database') - await db.connection().schema.dropTableIfExists('users') - await db.connection('secondary').schema.dropTableIfExists('users') - await db.manager.closeAll() - await fs.cleanup() -} - -/** - * Reset database tables - */ -export async function reset(application: ApplicationContract) { - const db = application.container.use('Adonis/Lucid/Database') - await db.connection().truncate('users') - await db.connection('secondary').truncate('users') - - await db.connection().truncate('api_tokens') - await db.connection('secondary').truncate('api_tokens') -} - -/** - * Returns an instance of the lucid provider - */ -export function getLucidProvider( - application: ApplicationContract, - config: MarkOptional, 'driver' | 'uids' | 'identifierKey' | 'user'> -) { - const defaults = getLucidProviderConfig(config) - const normalizedConfig = Object.assign(defaults, config) as LucidProviderConfig - return new LucidProvider(application, normalizedConfig) as unknown as LucidProviderContract -} - -/** - * Returns an instance of the database provider - */ -export function getDatabaseProvider( - application: ApplicationContract, - config: Partial -) { - const defaults = getDatabaseProviderConfig() - const normalizedConfig = Object.assign(defaults, config) as DatabaseProviderConfig - return new DatabaseProvider( - application, - normalizedConfig, - application.container.use('Adonis/Lucid/Database') - ) as unknown as DatabaseProviderContract -} - -/** - * Returns an instance of the session driver. - */ -export function getSessionDriver( - app: ApplicationContract, - provider: DatabaseProviderContract | LucidProviderContract, - providerConfig: DatabaseProviderConfig | LucidProviderConfig, - ctx: HttpContextContract, - name?: string -) { - const config = { - driver: 'session' as const, - loginRoute: '/login', - provider: providerConfig, - } - - return new SessionGuard( - name || 'session', - config, - app.container.use('Adonis/Core/Event'), - provider, - ctx - ) -} - -/** - * Returns an instance of the session client. - */ -export function getSessionClient( - provider: DatabaseProviderContract | LucidProviderContract, - providerConfig: DatabaseProviderConfig | LucidProviderConfig, - name?: string -) { - const config = { - driver: 'session' as const, - loginRoute: '/login', - provider: providerConfig, - } - - return new SessionClient(name || 'session', config, provider) -} - -/** - * Returns an instance of the OAT client. - */ -export function getOatClient( - provider: DatabaseProviderContract | LucidProviderContract, - providerConfig: DatabaseProviderConfig | LucidProviderConfig, - tokensProvider: TokenProviderContract, - tokenProviderConfig?: Partial -) { - const config = { - driver: 'oat' as const, - tokenProvider: { - driver: 'database' as const, - table: 'api_tokens', - ...tokenProviderConfig, - }, - provider: providerConfig, - } - - return new OATClient('api', config, provider, tokensProvider) -} - -/** - * Returns an instance of the api tokens guard. - */ -export function getApiTokensGuard( - app: ApplicationContract, - provider: DatabaseProviderContract | LucidProviderContract, - providerConfig: DatabaseProviderConfig | LucidProviderConfig, - ctx: HttpContextContract, - tokensProvider: TokenProviderContract, - tokenProviderConfig?: DatabaseTokenProviderConfig -) { - const config = { - driver: 'oat' as const, - tokenProvider: tokenProviderConfig || { - driver: 'database' as const, - table: 'api_tokens', - }, - provider: providerConfig, - } - - return new OATGuard( - 'api', - config, - app.container.use('Adonis/Core/Event'), - provider, - ctx, - tokensProvider - ) -} - -/** - * Returns an instance of the basic auth guard. - */ -export function getBasicAuthGuard( - app: ApplicationContract, - provider: DatabaseProviderContract | LucidProviderContract, - providerConfig: DatabaseProviderConfig | LucidProviderConfig, - ctx: HttpContextContract -) { - const config = { - driver: 'basic' as const, - realm: 'Login', - provider: providerConfig, - } - - return new BasicAuthGuard('basic', config, app.container.use('Adonis/Core/Event'), provider, ctx) -} - -/** - * Returns the database token provider - */ -export function getTokensDbProvider( - db: DatabaseContract, - config?: Partial -) { - return new TokenDatabaseProvider( - { - table: 'api_tokens', - driver: 'database', - ...config, - }, - db - ) -} - -/** - * Returns the database token provider - */ -export function getTokensRedisProvider(redis: RedisManagerContract) { - return new TokenRedisProvider( - { - driver: 'redis', - redisConnection: 'local', - }, - redis - ) -} - -/** - * Returns the user model - */ -export function getUserModel(BaseModel: LucidModel) { - const UserModel = class User extends BaseModel { - public id: number - public username: string - public password: string - public email: string - public rememberMeToken: string - } - - UserModel.boot() - UserModel.$addColumn('id', { isPrimary: true }) - UserModel.$addColumn('username', {}) - UserModel.$addColumn('email', {}) - UserModel.$addColumn('password', {}) - UserModel.$addColumn('rememberMeToken', {}) - - return UserModel -} - -/** - * Signs value to be set as cookie header - */ -export function signCookie(app: ApplicationContract, value: any, name: string) { - return `${name}=s:${app.container - .use('Adonis/Core/Encryption') - .verifier.sign(value, undefined, name)}` -} - -/** - * Encrypt value to be set as cookie header - */ -export function encryptCookie(app: ApplicationContract, value: any, name: string) { - return `${name}=e:${app.container.use('Adonis/Core/Encryption').encrypt(value, undefined, name)}` -} - -/** - * Decrypt cookie - */ -export function decryptCookie(app: ApplicationContract, cookie: any, name: string) { - const cookieValue = decodeURIComponent(cookie.split(';')[0]).replace(`${name}=`, '').slice(2) - - return app.container.use('Adonis/Core/Encryption').decrypt(cookieValue, name) -} - -/** - * Unsign cookie - */ -export function unsignCookie(app: ApplicationContract, cookie: any, name: string) { - const cookieValue = decodeURIComponent(cookie.split(';')[0]).replace(`${name}=`, '').slice(2) - - return app.container.use('Adonis/Core/Encryption').verifier.unsign(cookieValue, name) -} - -/** - * Mocks action on a object - */ -export function mockAction(collection: any, name: string, verifier: any) { - collection[name] = function (...args: any[]) { - verifier(...args) - delete collection[name] - } -} - -/** - * Mocks property on a object - */ -export function mockProperty(collection: any, name: string, value: any) { - Object.defineProperty(collection, name, { - get() { - delete collection[name] - return value - }, - enumerable: true, - configurable: true, - }) -} diff --git a/test/auth-manager.spec.ts b/test/auth-manager.spec.ts deleted file mode 100644 index 23afabd..0000000 --- a/test/auth-manager.spec.ts +++ /dev/null @@ -1,349 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { UserProviderContract } from '@ioc:Adonis/Addons/Auth' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { Auth } from '../src/Auth' -import { OATGuard } from '../src/Guards/Oat' -import { AuthManager } from '../src/AuthManager' -import { SessionGuard } from '../src/Guards/Session' -import { BasicAuthGuard } from '../src/Guards/BasicAuth' -import { LucidProvider } from '../src/UserProviders/Lucid' -import { DatabaseProvider } from '../src/UserProviders/Database' - -import { - setup, - reset, - cleanup, - getUserModel, - setupApplication, - getLucidProviderConfig, - getDatabaseProviderConfig, -} from '../test-helpers' - -let app: ApplicationContract - -test.group('Auth Manager', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('make an instance of the session guard with lucid provider', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - - const mapping = manager.makeMapping(ctx, 'session') - assert.instanceOf(mapping, SessionGuard) - assert.instanceOf(mapping.provider, LucidProvider) - }) - - test('make an instance of the session guard with database provider', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - - const mapping = manager.makeMapping(ctx, 'sessionDb') - assert.instanceOf(mapping, SessionGuard) - assert.instanceOf(mapping.provider, DatabaseProvider) - }) - - test('make an instance of auth class for a given http request', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - - const auth = manager.getAuthForRequest(ctx) - assert.instanceOf(auth, Auth) - }) - - test('extend by adding custom provider', ({ assert }) => { - class MongoDBProvider implements UserProviderContract { - constructor(config: any) { - assert.deepEqual(config, { driver: 'mongodb' }) - } - - public getUserFor(): any {} - public async findById(): Promise {} - public async findByRememberMeToken(): Promise {} - public async findByUid(): Promise {} - public async updateRememberMeToken() {} - } - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - session: {}, - admin: { - driver: 'session', - provider: { - driver: 'mongodb', - }, - }, - }, - } as any) - - manager.extend('provider', 'mongodb', (auth, mapping, config) => { - assert.deepEqual(auth, manager) - assert.equal(mapping, 'admin') - return new MongoDBProvider(config) - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - assert.instanceOf(manager.makeMapping(ctx, 'admin' as any).provider, MongoDBProvider) - }) - - test('extend by adding custom guard', ({ assert }) => { - class MongoDBProvider implements UserProviderContract { - constructor(config: any) { - assert.deepEqual(config, { driver: 'mongodb' }) - } - - public getUserFor(): any {} - public async findById(): Promise {} - public async findByRememberMeToken(): Promise {} - public async findByUid(): Promise {} - public async updateRememberMeToken() {} - } - - class CustomGuard { - constructor(mapping: string, config: any, public provider: any) { - assert.equal(mapping, 'admin') - assert.deepEqual(config, { driver: 'google', provider: { driver: 'mongodb' } }) - } - } - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - session: {}, - admin: { - driver: 'google', - provider: { - driver: 'mongodb', - }, - }, - }, - } as any) - - manager.extend('provider', 'mongodb', (_, __, config) => { - return new MongoDBProvider(config) - }) - - manager.extend('guard', 'google', (auth, mapping, config, provider) => { - assert.deepEqual(auth, manager) - return new CustomGuard(mapping, config, provider) as any - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - assert.instanceOf(manager.makeMapping(ctx, 'admin' as any), CustomGuard) - assert.instanceOf(manager.makeMapping(ctx, 'admin' as any).provider, MongoDBProvider) - }) - - test('make an instance of the oat guard with lucid provider', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'api', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - - const mapping = manager.makeMapping(ctx, 'api') - assert.instanceOf(mapping, OATGuard) - assert.instanceOf(mapping.provider, LucidProvider) - }) - - test('make an instance of the basic auth guard with lucid provider', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'api', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - - const mapping = manager.makeMapping(ctx, 'basic') - assert.instanceOf(mapping, BasicAuthGuard) - assert.instanceOf(mapping.provider, LucidProvider) - }) -}) diff --git a/test/auth-provider.spec.ts b/test/auth-provider.spec.ts deleted file mode 100644 index a4cbd59..0000000 --- a/test/auth-provider.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { createServer } from 'http' -import { test } from '@japa/runner' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { AuthManager } from '../src/AuthManager' -import { setupApplication, setup, getUserModel, cleanup } from '../test-helpers' - -let app: ApplicationContract - -test.group('Auth Provider', (group) => { - group.each.setup(async () => { - app = await setupApplication( - ['@japa/preset-adonis/TestsProvider', '../../providers/AuthProvider'], - 'test', - 'memory' - ) - - return () => cleanup(app) - }) - - group.each.teardown(() => { - app.container.resolveBinding('Japa/Preset/ApiClient').clearRequestHandlers() - app.container.resolveBinding('Japa/Preset/ApiClient').clearSetupHooks() - app.container.resolveBinding('Japa/Preset/ApiClient').clearTeardownHooks() - }) - - test('register auth provider', async ({ assert }) => { - assert.instanceOf(app.container.use('Adonis/Addons/Auth'), AuthManager) - }) - - test('define auth property on http context', async ({ assert }) => { - assert.isTrue(app.container.use('Adonis/Core/HttpContext')['hasGetter']('auth')) - }) - - test('login user using session', async ({ assert }) => { - await setup(app) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - email: 'virk@adonisjs.com', - username: 'virk', - password: 'secret', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - await ctx.session.initiate(false) - await ctx.auth.check() - - try { - ctx.response.send(ctx.auth.user) - } catch (error) { - ctx.response.status(500).send(error.stack) - } - - ctx.response.finish() - }) - server.listen(3333) - - const client = new (app.container.use('Japa/Preset/ApiClient'))('http://localhost:3333') - const response = await client.get('/').loginAs({ id: user.id }) - - server.close() - - assert.deepEqual(response.status(), 200) - assert.containsSubset(response.body(), { username: 'virk', email: 'virk@adonisjs.com' }) - }) - - test('login user using custom guard', async ({ assert }) => { - await setup(app) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - email: 'virk@adonisjs.com', - username: 'virk', - password: 'secret', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - await ctx.session.initiate(false) - await ctx.auth.use('apiDb').check() - - try { - ctx.response.send(ctx.auth.use('apiDb').user) - } catch (error) { - ctx.response.status(500).send(error.stack) - } - - ctx.response.finish() - }) - server.listen(3333) - - const client = new (app.container.use('Japa/Preset/ApiClient'))('http://localhost:3333') - const response = await client.get('/').guard('apiDb').loginAs({ id: user.id }) - server.close() - - assert.deepEqual(response.status(), 200) - assert.containsSubset(response.body(), { username: 'virk', email: 'virk@adonisjs.com' }) - }) -}) diff --git a/test/auth.spec.ts b/test/auth.spec.ts deleted file mode 100644 index 99a1624..0000000 --- a/test/auth.spec.ts +++ /dev/null @@ -1,503 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { AuthManager } from '../src/AuthManager' -import { SessionGuard } from '../src/Guards/Session' -import { LucidProvider } from '../src/UserProviders/Lucid' -import { DatabaseProvider } from '../src/UserProviders/Database' -import { TokenRedisProvider } from '../src/TokenProviders/Redis' -import { TokenDatabaseProvider } from '../src/TokenProviders/Database' - -import { - setup, - reset, - cleanup, - mockAction, - mockProperty, - getUserModel, - setupApplication, - getLucidProviderConfig, - getDatabaseProviderConfig, -} from '../test-helpers' - -let app: ApplicationContract - -test.group('Auth', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('make and cache instance of the session guard', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx) - const mapping = auth.use('session') - mapping['isCached'] = true - - assert.equal(auth.use('session')['isCached'], true) - assert.instanceOf(mapping, SessionGuard) - assert.instanceOf(mapping.provider, LucidProvider) - }) - - test('proxy all methods to the default driver', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'custom', - guards: { - custom: { - driver: 'custom', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - } as any) - - class CustomGuard {} - const guardInstance = new CustomGuard() - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx) - - manager.extend('guard', 'custom', () => { - return guardInstance as any - }) - - /** - * Test attempt - */ - mockAction( - guardInstance, - 'attempt', - function (uid: string, secret: string, rememberMe: boolean) { - assert.equal(uid, 'foo') - assert.equal(secret, 'secret') - assert.equal(rememberMe, true) - } - ) - await auth.attempt('foo', 'secret', true) - - /** - * Test verify credentails - */ - mockAction(guardInstance, 'verifyCredentials', function (uid: string, secret: string) { - assert.equal(uid, 'foo') - assert.equal(secret, 'secret') - }) - await auth.verifyCredentials('foo', 'secret') - - /** - * Test login - */ - mockAction(guardInstance, 'login', function (user: any, rememberMe: boolean) { - assert.deepEqual(user, { id: 1 }) - assert.equal(rememberMe, true) - }) - await auth.login({ id: 1 }, true) - - /** - * Test loginViaId - */ - mockAction(guardInstance, 'loginViaId', function (id: number, rememberMe: boolean) { - assert.deepEqual(id, 1) - assert.equal(rememberMe, true) - }) - await auth.loginViaId(1, true) - - /** - * Test logout - */ - mockAction(guardInstance, 'logout', function (renewToken: boolean) { - assert.equal(renewToken, true) - }) - await auth.logout(true) - - /** - * Test authenticate - */ - mockAction(guardInstance, 'authenticate', function () { - assert.isTrue(true) - }) - await auth.authenticate() - - /** - * Test check - */ - mockAction(guardInstance, 'check', function () { - assert.isTrue(true) - }) - await auth.check() - - /** - * Test isGuest - */ - mockProperty(guardInstance, 'isGuest', false) - assert.isFalse(auth.isGuest) - - /** - * Test user - */ - mockProperty(guardInstance, 'user', { id: 1 }) - assert.deepEqual(auth.user, { id: 1 }) - - /** - * Test isLoggedIn - */ - mockProperty(guardInstance, 'isLoggedIn', true) - assert.isTrue(auth.isLoggedIn) - - /** - * Test isLoggedOut - */ - mockProperty(guardInstance, 'isLoggedOut', true) - assert.isTrue(auth.isLoggedOut) - - /** - * Test authenticationAttempted - */ - mockProperty(guardInstance, 'authenticationAttempted', true) - assert.isTrue(auth.authenticationAttempted) - }) - - test('update default guard', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx) - auth.defaultGuard = 'sessionDb' - - assert.equal(auth.name, 'sessionDb') - assert.instanceOf(auth.provider, DatabaseProvider) - }) - - test('serialize toJSON', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'session', - guards: { - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx) - auth.defaultGuard = 'sessionDb' - auth.use() - - assert.deepEqual(auth.toJSON(), { - defaultGuard: 'sessionDb', - guards: { - sessionDb: { - isLoggedIn: false, - isGuest: true, - viaRemember: false, - user: undefined, - authenticationAttempted: false, - isAuthenticated: false, - }, - }, - }) - }) - - test('make oat guard', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'api', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx).use('api') - - assert.equal(auth.name, 'api') - assert.instanceOf(auth.provider, LucidProvider) - assert.instanceOf(auth.tokenProvider, TokenDatabaseProvider) - }) - - test('make oat guard with redis driver', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'api', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'redis', - redisConnection: 'local', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'redis', - redisConnection: 'local', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx).use('api') - - assert.equal(auth.name, 'api') - assert.instanceOf(auth.provider, LucidProvider) - assert.instanceOf(auth.tokenProvider, TokenRedisProvider) - }) - - test('return user_id when foreignKey is missing', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'api', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx).use('api') - - assert.instanceOf(auth.tokenProvider, TokenDatabaseProvider) - assert.equal(auth.tokenProvider.foreignKey, 'user_id') - }) - - test('return the foreignKey when not missing', ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const manager = new AuthManager(app, { - guard: 'api', - guards: { - api: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - foreignKey: 'account_id', - }, - provider: getLucidProviderConfig({ model: async () => User }), - }, - apiDb: { - driver: 'oat', - tokenProvider: { - driver: 'database', - table: 'api_tokens', - foreignKey: 'account_id', - }, - provider: getDatabaseProviderConfig(), - }, - basic: { - driver: 'basic', - provider: getLucidProviderConfig({ model: async () => User }), - }, - session: { - driver: 'session', - provider: getLucidProviderConfig({ model: async () => User }), - }, - sessionDb: { - driver: 'session', - provider: getDatabaseProviderConfig(), - }, - }, - }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const auth = manager.getAuthForRequest(ctx).use('api') - - assert.instanceOf(auth.tokenProvider, TokenDatabaseProvider) - assert.equal(auth.tokenProvider.foreignKey, 'account_id') - }) -}) diff --git a/test/clients/oat.spec.ts b/test/clients/oat.spec.ts deleted file mode 100644 index ee890e0..0000000 --- a/test/clients/oat.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createHash } from 'crypto' -import { base64 } from '@poppinss/utils/build/helpers' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { - setup, - reset, - cleanup, - getUserModel, - getOatClient, - setupApplication, - getLucidProvider, - getTokensDbProvider, - getLucidProviderConfig, -} from '../../test-helpers' - -let app: ApplicationContract - -test.group('OAT Client | login', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('login user and return the token', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const tokensProvider = getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - - const client = getOatClient( - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - tokensProvider - ) - - const { headers } = await client.login(user) - assert.properties(headers, ['Authorization']) - - const [id, value] = headers!.Authorization.replace('Bearer ', '').split('.') - - const token = await tokensProvider.read( - base64.urlDecode(id, undefined, true)!, - createHash('sha256').update(value).digest('hex'), - 'opaque_token' - ) - - assert.equal(token!.userId, user.id) - }) - - test('login user and return the token when mapping name is different', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const tokensProvider = getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - - const client = getOatClient( - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - tokensProvider, - { type: 'pat' } - ) - - const { headers } = await client.login(user) - assert.properties(headers, ['Authorization']) - - const [id, value] = headers!.Authorization.replace('Bearer ', '').split('.') - - const token = await tokensProvider.read( - base64.urlDecode(id, undefined, true)!, - createHash('sha256').update(value).digest('hex'), - 'pat' - ) - - assert.equal(token!.userId, user.id) - }) - - test('delete token on logout', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const tokensProvider = getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - - const client = getOatClient( - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - tokensProvider - ) - - const { headers } = await client.login(user) - assert.properties(headers, ['Authorization']) - - await client.logout() - - const [id, value] = headers!.Authorization.replace('Bearer ', '').split('.') - const token = await tokensProvider.read( - base64.urlDecode(id, undefined, true)!, - createHash('sha256').update(value).digest('hex'), - 'opaque_token' - ) - - assert.isNull(token) - }) -}) diff --git a/test/clients/session.spec.ts b/test/clients/session.spec.ts deleted file mode 100644 index 83da902..0000000 --- a/test/clients/session.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { - setup, - reset, - cleanup, - getUserModel, - setupApplication, - getSessionClient, - getLucidProvider, - getLucidProviderConfig, -} from '../../test-helpers' - -let app: ApplicationContract - -test.group('Session Client | login', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('login user and return the session', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - - const client = getSessionClient( - lucidProvider, - getLucidProviderConfig({ model: async () => User }) - ) - - const { session } = await client.login(user) - assert.deepEqual(session, { - auth_session: user.id, - }) - }) - - test('login user and return the session when mapping name is different', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - - const client = getSessionClient( - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - 'foo' - ) - - const { session } = await client.login(user) - assert.deepEqual(session, { - auth_foo: user.id, - }) - }) -}) diff --git a/test/guards/basic-auth.spec.ts b/test/guards/basic-auth.spec.ts deleted file mode 100644 index 8b3c6db..0000000 --- a/test/guards/basic-auth.spec.ts +++ /dev/null @@ -1,339 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import supertest from 'supertest' -import { createServer } from 'http' -import { base64 } from '@poppinss/utils/build/helpers' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { - setup, - reset, - cleanup, - getUserModel, - setupApplication, - getLucidProvider, - getBasicAuthGuard, - getLucidProviderConfig, -} from '../../test-helpers' - -let app: ApplicationContract - -test.group('Basic Auth Guard | authenticate', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('authenticate request by reading the basic auth credentials', async ({ assert }) => { - assert.plan(7) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const credentials = base64.encode(`${user.email}:secret`) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:basic:authenticate', ({ name, user: model }) => { - assert.equal(name, 'basic') - assert.instanceOf(model, User) - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const basicAuth = getBasicAuthGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await basicAuth.authenticate() - ctx.response.send(basicAuth.toJSON()) - } catch (error) { - await error.handle(error, { ...ctx, auth: { use: () => basicAuth } as any }) - } - - ctx.response.finish() - }) - - const { body } = await supertest(server) - .get('/') - .set('Authorization', `Basic ${credentials}`) - .expect(200) - - assert.isTrue(body.authenticationAttempted) - assert.isTrue(body.isAuthenticated) - assert.isFalse(body.isGuest) - assert.isTrue(body.isLoggedIn) - assert.property(body, 'user') - }) - - test('raise error when Authorization header is missing', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container.use('Adonis/Core/Event').once('adonis:basic:authenticate', () => { - throw new Error('Not expected to be invoked') - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const basicAuth = getBasicAuthGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await basicAuth.authenticate() - ctx.response.send(basicAuth.toJSON()) - } catch (error) { - await error.handle(error, { ...ctx, auth: { use: () => basicAuth } as any }) - } - - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/').expect(401) - assert.deepEqual(header['www-authenticate'], 'Basic realm="Login", charset="UTF-8"') - }) - - test('raise error when type is not basic', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container.use('Adonis/Core/Event').once('adonis:basic:authenticate', () => { - throw new Error('Not expected to be invoked') - }) - - const credentials = base64.encode(`${user.email}:secret`) - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const basicAuth = getBasicAuthGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await basicAuth.authenticate() - ctx.response.send(basicAuth.toJSON()) - } catch (error) { - await error.handle(error, { ...ctx, auth: { use: () => basicAuth } as any }) - } - - ctx.response.finish() - }) - - const { header } = await supertest(server) - .get('/') - .set('Authorization', `Foo ${credentials}`) - .expect(401) - - assert.deepEqual(header['www-authenticate'], 'Basic realm="Login", charset="UTF-8"') - }) - - test('raise error when credentials are not base64 encoded', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container.use('Adonis/Core/Event').once('adonis:basic:authenticate', () => { - throw new Error('Not expected to be invoked') - }) - - const credentials = `${user.email}:secret` - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const basicAuth = getBasicAuthGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await basicAuth.authenticate() - ctx.response.send(basicAuth.toJSON()) - } catch (error) { - await error.handle(error, { ...ctx, auth: { use: () => basicAuth } as any }) - } - - ctx.response.finish() - }) - - const { header } = await supertest(server) - .get('/') - .set('Authorization', `Foo ${credentials}`) - .expect(401) - - assert.deepEqual(header['www-authenticate'], 'Basic realm="Login", charset="UTF-8"') - }) - - test('raise error when credentials are not separated using colon', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container.use('Adonis/Core/Event').once('adonis:basic:authenticate', () => { - throw new Error('Not expected to be invoked') - }) - - const credentials = base64.encode(`${user.email}_secret`) - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const basicAuth = getBasicAuthGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await basicAuth.authenticate() - ctx.response.send(basicAuth.toJSON()) - } catch (error) { - await error.handle(error, { ...ctx, auth: { use: () => basicAuth } as any }) - } - - ctx.response.finish() - }) - - const { header } = await supertest(server) - .get('/') - .set('Authorization', `Foo ${credentials}`) - .expect(401) - - assert.deepEqual(header['www-authenticate'], 'Basic realm="Login", charset="UTF-8"') - }) - - test('raise error when uid is incorrect', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container.use('Adonis/Core/Event').once('adonis:basic:authenticate', () => { - throw new Error('Not expected to be invoked') - }) - - const credentials = base64.encode(`foo:secret`) - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const basicAuth = getBasicAuthGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await basicAuth.authenticate() - ctx.response.send(basicAuth.toJSON()) - } catch (error) { - await error.handle(error, { ...ctx, auth: { use: () => basicAuth } as any }) - } - - ctx.response.finish() - }) - - const { header } = await supertest(server) - .get('/') - .set('Authorization', `Foo ${credentials}`) - .expect(401) - - assert.deepEqual(header['www-authenticate'], 'Basic realm="Login", charset="UTF-8"') - }) - - test('raise error when password is incorrect', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container.use('Adonis/Core/Event').once('adonis:basic:authenticate', () => { - throw new Error('Not expected to be invoked') - }) - - const credentials = base64.encode(`${user.email}:helloworld`) - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const basicAuth = getBasicAuthGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await basicAuth.authenticate() - ctx.response.send(basicAuth.toJSON()) - } catch (error) { - await error.handle(error, { ...ctx, auth: { use: () => basicAuth } as any }) - } - - ctx.response.finish() - }) - - const { header } = await supertest(server) - .get('/') - .set('Authorization', `Foo ${credentials}`) - .expect(401) - - assert.deepEqual(header['www-authenticate'], 'Basic realm="Login", charset="UTF-8"') - }) -}) diff --git a/test/guards/oat.spec.ts b/test/guards/oat.spec.ts deleted file mode 100644 index 7f9261a..0000000 --- a/test/guards/oat.spec.ts +++ /dev/null @@ -1,957 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { DateTime } from 'luxon' -import supertest from 'supertest' -import { createServer } from 'http' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { OpaqueToken } from '../../src/Tokens/OpaqueToken' -import { ProviderToken } from '../../src/Tokens/ProviderToken' - -import { - setup, - reset, - cleanup, - getUserModel, - setupApplication, - getLucidProvider, - getApiTokensGuard, - getTokensDbProvider, - getLucidProviderConfig, -} from '../../test-helpers' - -let app: ApplicationContract - -test.group('OAT Guard | Verify Credentials', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('raise exception when unable to lookup user', async ({ assert }) => { - assert.plan(1) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await apiTokensGuard.verifyCredentials('virk@adonisjs.com', 'password') - } catch (error) { - assert.deepEqual(error.message, 'E_INVALID_AUTH_UID: User not found') - } - }) - - test('raise exception when password is incorrect', async ({ assert }) => { - assert.plan(1) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await apiTokensGuard.verifyCredentials('virk@adonisjs.com', 'password') - } catch (error) { - assert.deepEqual(error.message, 'E_INVALID_AUTH_PASSWORD: Password mis-match') - } - }) - - test('return user when able to verify credentials', async ({ assert }) => { - assert.plan(1) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - const user = await apiTokensGuard.verifyCredentials('virk@adonisjs.com', 'secret') - assert.instanceOf(user, User) - }) -}) - -test.group('OAT Guard | attempt', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('return token with user from the attempt call', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container.use('Adonis/Core/Event').once('adonis:api:login', ({ name, user, token }) => { - assert.equal(name, 'api') - assert.instanceOf(user, User) - assert.instanceOf(token, OpaqueToken) - }) - - const token = await apiTokensGuard.attempt('virk@adonisjs.com', 'secret') - const tokens = await app.container.use('Adonis/Lucid/Database').query().from('api_tokens') - - /** - * Assert correct token is generated and persisted to the db - */ - assert.equal(token.type, 'bearer') - assert.instanceOf(token.user, User) - assert.isUndefined(token.expiresAt) - assert.lengthOf(tokens, 1) - assert.isNull(tokens[0].expires_at) - }) - - test('define custom expiry', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:login', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, OpaqueToken) - }) - - const token = await apiTokensGuard.attempt('virk@adonisjs.com', 'secret', { - expiresIn: '3mins', - }) - const tokens = await app.container.use('Adonis/Lucid/Database').query().from('api_tokens') - - /** - * Assert correct token is generated and persisted to the db - */ - assert.lengthOf(tokens, 1) - assert.isBelow(token.expiresAt.diff(DateTime.local(), 'minutes').minutes, 4) - assert.isBelow( - DateTime.fromSQL(tokens[0].expires_at).diff(DateTime.local(), 'minutes').minutes, - 4 - ) - }) - - test('define custom name for the token', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:login', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, OpaqueToken) - }) - - const token = await apiTokensGuard.attempt('virk@adonisjs.com', 'secret', { - name: 'Android token', - }) - const tokens = await app.container - .use('Adonis/Lucid/Database') - .query() - .from('api_tokens') - .where('user_id', user.id) - - /** - * Assert correct token is generated and persisted to the db - */ - assert.lengthOf(tokens, 1) - assert.equal(token.name, 'Android token') - assert.equal(tokens[0].name, 'Android token') - }) - - test('define meta data to be persisted inside the database', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:login', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, OpaqueToken) - }) - - const token = await apiTokensGuard.attempt('virk@adonisjs.com', 'secret', { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - const tokens = await app.container - .use('Adonis/Lucid/Database') - .query() - .from('api_tokens') - .where('user_id', user.id) - - /** - * Assert correct token is generated and persisted to the db - */ - assert.lengthOf(tokens, 1) - assert.equal(tokens[0].device_name, 'Android') - assert.equal(tokens[0].ip_address, '192.168.1.1') - assert.deepEqual(token.meta, { device_name: 'Android', ip_address: '192.168.1.1' }) - }) -}) - -test.group('OAT Guard | login', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('login using user instance', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:login', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, OpaqueToken) - }) - - const token = await apiTokensGuard.login(user, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - const tokens = await app.container - .use('Adonis/Lucid/Database') - .query() - .from('api_tokens') - .where('user_id', user.id) - - /** - * Assert correct token is generated and persisted to the db - */ - assert.lengthOf(tokens, 1) - assert.equal(tokens[0].device_name, 'Android') - assert.equal(tokens[0].ip_address, '192.168.1.1') - assert.deepEqual(token.meta, { device_name: 'Android', ip_address: '192.168.1.1' }) - }) - - test('use custom token type', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')), - { - driver: 'database', - table: 'api_tokens', - type: 'access_token', - } - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:login', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, OpaqueToken) - assert.equal(token.type, 'bearer') - }) - - const token = await apiTokensGuard.login(user, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - const tokens = await app.container - .use('Adonis/Lucid/Database') - .query() - .from('api_tokens') - .where('user_id', user.id) - - /** - * Assert correct token is generated and persisted to the db - */ - assert.lengthOf(tokens, 1) - assert.equal(tokens[0].device_name, 'Android') - assert.equal(tokens[0].ip_address, '192.168.1.1') - assert.equal(tokens[0].type, 'access_token') - assert.deepEqual(token.meta, { device_name: 'Android', ip_address: '192.168.1.1' }) - }) -}) - -test.group('OAT Guard | loginViaId', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('login using user id', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:login', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, OpaqueToken) - }) - - const token = await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - const tokens = await app.container - .use('Adonis/Lucid/Database') - .query() - .from('api_tokens') - .where('user_id', user.id) - - /** - * Assert correct token is generated and persisted to the db - */ - assert.lengthOf(tokens, 1) - assert.equal(tokens[0].device_name, 'Android') - assert.equal(tokens[0].ip_address, '192.168.1.1') - assert.deepEqual(token.meta, { device_name: 'Android', ip_address: '192.168.1.1' }) - }) -}) - -test.group('OAT Guard | authenticate', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('authenticate request by reading the bearer token', async ({ assert }) => { - assert.plan(6) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:authenticate', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, ProviderToken) - }) - - const token = await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.authenticate() - ctx.response.send(oat1.token) - } catch (error) { - ctx.response.status(500).send(error) - } - - ctx.response.finish() - }) - - const { body } = await supertest(server) - .get('/') - .set('Authorization', `${token.type} ${token.token}`) - .expect(200) - - assert.equal(body.name, 'Opaque Access Token') - assert.equal(body.type, 'opaque_token') - assert.exists(body.tokenHash) - }) - - test('raise error when Authorization header is missing', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.authenticate() - ctx.response.send(oat1.token) - } catch (error) { - error.handle(error, { ...ctx, auth: { use: () => oat1 } as any }) - } - - ctx.response.finish() - }) - - const { body } = await supertest(server).get('/').set('Accept', 'application/json').expect(401) - - assert.deepEqual(body, { - errors: [{ message: 'E_INVALID_API_TOKEN: Invalid API token' }], - }) - }) - - test('raise error when token is malformed', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.authenticate() - ctx.response.send(oat1.token) - } catch (error) { - error.handle(error, { ...ctx, auth: { use: () => oat1 } as any }) - } - - ctx.response.finish() - }) - - const { body } = await supertest(server) - .get('/') - .set('Accept', 'application/json') - .set('Authorization', 'Bearer foobar') - .expect(401) - - assert.deepEqual(body, { - errors: [{ message: 'E_INVALID_API_TOKEN: Invalid API token' }], - }) - }) - - test('raise error when token is missing in the database', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - const token = await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.authenticate() - ctx.response.send(oat1.token) - } catch (error) { - error.handle(error, { ...ctx, auth: { use: () => oat1 } as any }) - } - - ctx.response.finish() - }) - - await app.container.use('Adonis/Lucid/Database').from('api_tokens').del() - const { body } = await supertest(server) - .get('/') - .set('Accept', 'application/json') - .set('Authorization', `${token.type} ${token.token}`) - .expect(401) - - assert.deepEqual(body, { - errors: [{ message: 'E_INVALID_API_TOKEN: Invalid API token' }], - }) - }) - - test('raise error when token is valid but user is missing', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - const token = await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.authenticate() - ctx.response.send(oat1.token) - } catch (error) { - error.handle(error, { ...ctx, auth: { use: () => oat1 } as any }) - } - - ctx.response.finish() - }) - - await app.container.use('Adonis/Lucid/Database').from('users').del() - const { body } = await supertest(server) - .get('/') - .set('Accept', 'application/json') - .set('Authorization', `${token.type} ${token.token}`) - .expect(401) - - assert.deepEqual(body, { - errors: [{ message: 'E_INVALID_API_TOKEN: Invalid API token' }], - }) - }) - - test('raise error when token is expired', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - const token = await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - - await app.container - .use('Adonis/Lucid/Database') - .from('api_tokens') - .update({ - expires_at: DateTime.local().minus({ days: 1 }).toJSDate(), - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.authenticate() - ctx.response.send(oat1.token) - } catch (error) { - error.handle(error, { ...ctx, auth: { use: () => oat1 } as any }) - } - - ctx.response.finish() - }) - - const { body } = await supertest(server) - .get('/') - .set('Accept', 'application/json') - .set('Authorization', `${token.type} ${token.token}`) - .expect(401) - - assert.deepEqual(body, { - errors: [{ message: 'E_INVALID_API_TOKEN: Invalid API token' }], - }) - }) - - test('work fine when token is not expired', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - const token = await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - expiresIn: '30 mins', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.authenticate() - ctx.response.send(oat1.token) - } catch (error) { - error.handle(error, { ...ctx, auth: { use: () => oat1 } as any }) - } - - ctx.response.finish() - }) - - const { body } = await supertest(server) - .get('/') - .set('Accept', 'application/json') - .set('Authorization', `${token.type} ${token.token}`) - .expect(200) - - assert.equal(body.name, 'Opaque Access Token') - assert.equal(body.type, 'opaque_token') - assert.exists(body.tokenHash) - }) -}) - -test.group('OAT Guard | logout', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('delete user token during logout', async ({ assert }) => { - assert.plan(6) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - password: await app.container.use('Adonis/Core/Hash').make('secret'), - }) - - const apiTokensGuard = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - app.container.use('Adonis/Core/HttpContext').create('/', {}), - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - /** - * Assert the event is fired with correct set of arguments - */ - app.container - .use('Adonis/Core/Event') - .once('adonis:api:authenticate', ({ name, user: model, token }) => { - assert.equal(name, 'api') - assert.instanceOf(model, User) - assert.instanceOf(token, ProviderToken) - }) - - const token = await apiTokensGuard.loginViaId(user.id, { - device_name: 'Android', - ip_address: '192.168.1.1', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const oat1 = getApiTokensGuard( - app, - getLucidProvider(app, { model: async () => User }), - getLucidProviderConfig({ model: async () => User }), - ctx, - getTokensDbProvider(app.container.use('Adonis/Lucid/Database')) - ) - - try { - await oat1.logout() - ctx.response.send(oat1.toJSON()) - } catch (error) { - ctx.response.status(500).send(error) - } - - ctx.response.finish() - }) - - const { body } = await supertest(server) - .get('/') - .set('Authorization', `${token.type} ${token.token}`) - .expect(200) - - const tokens = await app.container.use('Adonis/Lucid/Database').query().from('api_tokens') - assert.lengthOf(tokens, 0) - assert.isFalse(body.isLoggedIn) - assert.isTrue(body.authenticationAttempted) - }) -}) diff --git a/test/guards/session.spec.ts b/test/guards/session.spec.ts deleted file mode 100644 index 7403d8a..0000000 --- a/test/guards/session.spec.ts +++ /dev/null @@ -1,613 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import supertest from 'supertest' -import { createServer } from 'http' -import cookieParser from 'set-cookie-parser' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { - setup, - reset, - cleanup, - unsignCookie, - decryptCookie, - encryptCookie, - getUserModel, - setupApplication, - getSessionDriver, - getLucidProvider, - getLucidProviderConfig, -} from '../../test-helpers' - -let app: ApplicationContract - -test.group('Session Driver | Verify Credentials', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('raise exception when unable to lookup user', async ({ assert }) => { - assert.plan(1) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await sessionDriver.verifyCredentials('virk@adonisjs.com', 'password') - } catch (error) { - assert.deepEqual(error.message, 'E_INVALID_AUTH_UID: User not found') - } - }) - - test('raise exception when password is incorrect', async ({ assert }) => { - assert.plan(1) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await sessionDriver.verifyCredentials('virk@adonisjs.com', 'password') - } catch (error) { - assert.deepEqual(error.message, 'E_INVALID_AUTH_PASSWORD: Password mis-match') - } - }) - - test('return user when able to verify credentials', async ({ assert }) => { - assert.plan(1) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - const user = await sessionDriver.verifyCredentials('virk@adonisjs.com', 'secret') - assert.instanceOf(user, User) - }) -}) - -test.group('Session Driver | attempt', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('login user by setting the session', async ({ assert }) => { - assert.plan(4) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - app.container.use('Adonis/Core/Event').once('adonis:session:login', ({ name, user, token }) => { - assert.equal(name, 'session') - assert.instanceOf(user, User) - assert.isNull(token) - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - await sessionDriver.attempt('virk@adonisjs.com', 'secret') - ctx.response.send({}) - - await ctx.session.commit() - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - const sessionCookie = unsignCookie(app, header['set-cookie'][1], 'adonis-session') - const sessionValue = decryptCookie(app, header['set-cookie'][2], sessionCookie) - assert.deepEqual(sessionValue, { auth_session: 1 }) - }) - - test('define remember me cookie when remember me is set to true', async ({ assert }) => { - assert.plan(5) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - app.container - .use('Adonis/Core/Event') - .once('adonis:session:login', ({ name, user: model, token }) => { - assert.equal(name, 'session') - assert.instanceOf(model, User) - assert.exists(token) - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - await sessionDriver.attempt('virk@adonisjs.com', 'secret', true) - ctx.response.send({}) - - await ctx.session.commit() - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - const rememberMeCookie = decryptCookie(app, header['set-cookie'][0], 'remember_session') - const sessionCookie = unsignCookie(app, header['set-cookie'][1], 'adonis-session') - const sessionValue = decryptCookie(app, header['set-cookie'][2], sessionCookie) - - await user.refresh() - - assert.deepEqual(sessionValue, { auth_session: 1 }) - assert.equal(user.rememberMeToken!, rememberMeCookie.token) - }) - - test('delete remember_me cookie explicitly when login with remember me is false', async ({ - assert, - }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - await sessionDriver.attempt('virk@adonisjs.com', 'secret') - await ctx.session.commit() - ctx.response.finish() - }) - - const rememberMeToken = encryptCookie(app, '1234', 'remember_session') - const { header } = await supertest(server) - .get('/') - .set('cookie', `remember_session=${rememberMeToken}`) - const sessionCookie = unsignCookie(app, header['set-cookie'][1], 'adonis-session') - const sessionValue = decryptCookie(app, header['set-cookie'][2], sessionCookie) - const [key, maxAge, expiry] = header['set-cookie'][0].split(';') - - assert.equal(expiry.trim(), 'Expires=Thu, 01 Jan 1970 00:00:00 GMT') - assert.equal(maxAge.trim(), 'Max-Age=-1') - assert.isTrue(key.startsWith('remember_session=')) - assert.deepEqual(sessionValue, { auth_session: 1 }) - }) -}) - -test.group('Session Driver | authenticate', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('authenticate user session and load user from db', async ({ assert }) => { - assert.plan(8) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - app.container - .use('Adonis/Core/Event') - .once('adonis:session:authenticate', ({ name, user, viaRemember }) => { - assert.equal(name, 'session') - assert.instanceOf(user, User) - assert.isFalse(viaRemember) - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - if (req.url === '/login') { - await sessionDriver.attempt('virk@adonisjs.com', 'secret') - await ctx.session.commit() - ctx.response.finish() - } else { - await sessionDriver.authenticate() - ctx.response.send({ - user: sessionDriver.user, - isAuthenticated: sessionDriver.isAuthenticated, - viaRemember: sessionDriver.viaRemember, - }) - await ctx.session.commit() - ctx.response.finish() - } - }) - - const { header } = await supertest(server).get('/login') - const cookies = cookieParser(header['set-cookie']) - const reqCookies = cookies - .filter((cookie) => cookie.maxAge > 0) - .map((cookie) => `${cookie.name}=${cookie.value};`) - - const { body } = await supertest(server).get('/').set('cookie', reqCookies) - - assert.equal(body.user.id, 1) - assert.equal(body.user.username, 'virk') - assert.equal(body.user.email, 'virk@adonisjs.com') - assert.isTrue(body.isAuthenticated) - assert.isFalse(body.viaRemember) - }) - - test('re-login user using remember me token', async ({ assert }) => { - assert.plan(8) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - app.container - .use('Adonis/Core/Event') - .once('adonis:session:authenticate', ({ name, user, viaRemember }) => { - assert.equal(name, 'session') - assert.instanceOf(user, User) - assert.isTrue(viaRemember) - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - if (req.url === '/login') { - await sessionDriver.attempt('virk@adonisjs.com', 'secret', true) - await ctx.session.commit() - ctx.response.finish() - } else { - await sessionDriver.authenticate() - ctx.response.send({ - user: sessionDriver.user, - isAuthenticated: sessionDriver.isAuthenticated, - viaRemember: sessionDriver.viaRemember, - }) - await ctx.session.commit() - ctx.response.finish() - } - }) - - const { header } = await supertest(server).get('/login') - const cookies = cookieParser(header['set-cookie']) - - const rememberMeCookie = `${cookies[0].name}=${cookies[0].value};` - const { body } = await supertest(server).get('/').set('cookie', rememberMeCookie) - - assert.equal(body.user.id, 1) - assert.equal(body.user.username, 'virk') - assert.equal(body.user.email, 'virk@adonisjs.com') - assert.isTrue(body.isAuthenticated) - assert.isTrue(body.viaRemember) - }) - - test('raise exception when unable to authenticate', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - app.container.use('Adonis/Core/Event').once('adonis:session:authenticate', () => { - throw new Error('Never expected to reach here') - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - try { - await sessionDriver.authenticate() - } catch (error) { - assert.equal(error.message, 'E_INVALID_AUTH_SESSION: Invalid session') - assert.equal(error.guard, 'session') - assert.equal(error.redirectTo, '/login') - } - - await ctx.session.commit() - ctx.response.finish() - }) - - await supertest(server).get('/') - }) - - test('keep different guard session separate', async ({ assert }) => { - assert.plan(3) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - app.container - .use('Adonis/Core/Event') - .once('adonis:session:authenticate', ({ name, user, viaRemember }) => { - assert.equal(name, 'session') - assert.instanceOf(user, User) - assert.isFalse(viaRemember) - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - - /** - * Driver for user - */ - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx, - 'user' - ) - - /** - * Driver for org - */ - const otherSessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx, - 'org' - ) - - if (req.url === '/login') { - await sessionDriver.attempt('virk@adonisjs.com', 'secret') - await ctx.session.commit() - ctx.response.finish() - } else { - try { - await otherSessionDriver.authenticate() - } catch (error) {} - - ctx.response.send({ - user: otherSessionDriver.user, - isAuthenticated: otherSessionDriver.isAuthenticated, - viaRemember: otherSessionDriver.viaRemember, - }) - await ctx.session.commit() - ctx.response.finish() - } - }) - - const { header } = await supertest(server).get('/login') - const cookies = cookieParser(header['set-cookie']) - const reqCookies = cookies - .filter((cookie) => cookie.maxAge > 0) - .map((cookie) => `${cookie.name}=${cookie.value};`) - - const { body } = await supertest(server).get('/').set('cookie', reqCookies) - - assert.isUndefined(body.user) - assert.isFalse(body.isAuthenticated) - assert.isFalse(body.viaRemember) - }) -}) - -test.group('Session Driver | logout', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - app.container.use('Adonis/Core/Event')['clearAllListeners']() - }) - - test('logout the user by clearing up the session and removing remember_me cookie', async ({ - assert, - }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - if (req.url === '/login') { - await sessionDriver.attempt('virk@adonisjs.com', 'secret', true) - await ctx.session.commit() - ctx.response.finish() - } else { - await sessionDriver.logout() - ctx.response.send({ - user: sessionDriver.user, - isAuthenticated: sessionDriver.isAuthenticated, - isLoggedOut: sessionDriver.isLoggedOut, - }) - await ctx.session.commit() - ctx.response.finish() - } - }) - - const { header } = await supertest(server).get('/login') - await user.refresh() - - const initialToken = user.rememberMeToken - const cookies = cookieParser(header['set-cookie']) - const reqCookies = cookies - .filter((cookie) => cookie.maxAge > 0) - .map((cookie) => `${cookie.name}=${cookie.value};`) - - const { body, header: authHeaders } = await supertest(server).get('/').set('cookie', reqCookies) - - const rememberMeCookie = decryptCookie(app, authHeaders['set-cookie'][0], 'remember_session') - const sessionCookie = unsignCookie(app, authHeaders['set-cookie'][1], 'adonis-session') - const sessionValue = decryptCookie(app, authHeaders['set-cookie'][2], sessionCookie) - - assert.isNull(rememberMeCookie) - assert.deepEqual(sessionValue, {}) - assert.isNull(body.user) - assert.isFalse(body.isAuthenticated) - assert.isTrue(body.isLoggedOut) - - await user.refresh() - assert.equal(user.rememberMeToken, initialToken) - }) - - test('logout and recycle user remember me token', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const password = await app.container.use('Adonis/Core/Hash').make('secret') - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com', password }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const sessionDriver = getSessionDriver( - app, - lucidProvider, - getLucidProviderConfig({ model: async () => User }), - ctx - ) - - if (req.url === '/login') { - await sessionDriver.attempt('virk@adonisjs.com', 'secret', true) - await ctx.session.commit() - ctx.response.finish() - } else { - await sessionDriver.logout(true) - ctx.response.send({ - user: sessionDriver.user, - isAuthenticated: sessionDriver.isAuthenticated, - isLoggedOut: sessionDriver.isLoggedOut, - }) - await ctx.session.commit() - ctx.response.finish() - } - }) - - const { header } = await supertest(server).get('/login') - await user.refresh() - - const initialToken = user.rememberMeToken - const cookies = cookieParser(header['set-cookie']) - const reqCookies = cookies - .filter((cookie) => cookie.maxAge > 0) - .map((cookie) => `${cookie.name}=${cookie.value};`) - - const { body, header: authHeaders } = await supertest(server).get('/').set('cookie', reqCookies) - - const rememberMeCookie = decryptCookie(app, authHeaders['set-cookie'][0], 'remember_session') - const sessionCookie = unsignCookie(app, authHeaders['set-cookie'][1], 'adonis-session') - const sessionValue = decryptCookie(app, authHeaders['set-cookie'][2], sessionCookie) - - assert.isNull(rememberMeCookie) - assert.deepEqual(sessionValue, {}) - assert.isNull(body.user) - assert.isFalse(body.isAuthenticated) - assert.isTrue(body.isLoggedOut) - - await user.refresh() - assert.notEqual(user.rememberMeToken, initialToken) - }) -}) diff --git a/test/token-providers/database.spec.ts b/test/token-providers/database.spec.ts deleted file mode 100644 index 13aa84a..0000000 --- a/test/token-providers/database.spec.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import 'reflect-metadata' -import { DateTime } from 'luxon' -import { string } from '@poppinss/utils/build/helpers' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { setupApplication, cleanup, setup, reset, getTokensDbProvider } from '../../test-helpers' -let app: ApplicationContract - -const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) - -test.group('Database Token Provider', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('save token to the database', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local(), - }) - - assert.exists(tokenId) - }) - - test('use custom connection for persistance', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - provider.setConnection(db.connection('secondary')) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local(), - }) - - assert.exists(tokenId) - const secondaryConnectionTokens = await db.connection('secondary').from('api_tokens') - const primaryConnectionTokens = await db.connection().from('api_tokens') - - assert.lengthOf(secondaryConnectionTokens, 1) - assert.lengthOf(primaryConnectionTokens, 0) - }) - - test('read token from the database', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ minutes: 30 }), - }) - - const tokenRow = await provider.read(tokenId, token, 'api_token') - assert.equal(tokenRow!.name, 'Auth token') - assert.equal(tokenRow!.tokenHash, token) - assert.equal(tokenRow!.type, 'api_token') - assert.exists(tokenRow!.expiresAt) - }) - - test('read token from a custom database connection', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - provider.setConnection(db.connection('secondary')) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ minutes: 30 }), - }) - - const tokenRow = await provider.read(tokenId, token, 'api_token') - assert.equal(tokenRow!.name, 'Auth token') - assert.equal(tokenRow!.tokenHash, token) - assert.equal(tokenRow!.type, 'api_token') - assert.exists(tokenRow!.expiresAt) - }) - - test('return null when there is a token hash mis-match', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local(), - }) - - assert.isNull(await provider.read(tokenId, 'foo', 'api_token')) - }) - - test('return null when token has been expired', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local(), - }) - - await sleep(1000) - assert.isNull(await provider.read(tokenId, token, 'api_token')) - }) - - test('work fine when token has no expiry', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - }) - - await sleep(1000) - assert.isNotNull(await provider.read(tokenId, token, 'api_token')) - }) - - test('return null when token is missing', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local(), - }) - - assert.isNull(await provider.read(tokenId + 1, token, 'api_token')) - }) - - test('delete token from the database', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local(), - }) - - await provider.destroy(tokenId, 'api_token') - const tokens = await db.from('api_tokens').select('*') - assert.lengthOf(tokens, 0) - }) - - test('delete token from a custom database connection', async ({ assert }) => { - const token = string.generateRandom(40) - const db = app.container.use('Adonis/Lucid/Database') - const provider = getTokensDbProvider(db) - provider.setConnection(db.connection('secondary')) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local(), - }) - - await provider.destroy(tokenId, 'api_token') - const tokens = await db.connection('secondary').from('api_tokens').select('*') - assert.lengthOf(tokens, 0) - }) -}) diff --git a/test/token-providers/redis.spec.ts b/test/token-providers/redis.spec.ts deleted file mode 100644 index 2638991..0000000 --- a/test/token-providers/redis.spec.ts +++ /dev/null @@ -1,239 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import 'reflect-metadata' -import { DateTime } from 'luxon' -import { string } from '@poppinss/utils/build/helpers' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { setupApplication, cleanup, setup, reset, getTokensRedisProvider } from '../../test-helpers' -let app: ApplicationContract - -const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) - -test.group('Redis Token Provider', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - await app.container.use('Adonis/Addons/Redis').flushdb() - await app.container.use('Adonis/Addons/Redis').quit() - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('save token to the database', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ days: 2 }), - }) - - assert.exists(tokenId) - const tokenRow = JSON.parse((await redis.get(`api_token:${tokenId}`))!) - assert.deepEqual(tokenRow, { - user_id: '1', - name: 'Auth token', - token, - }) - - let expiry = await redis.ttl(tokenId) - assert.isBelow(expiry, 2 * 24 * 3600 + 1) - }) - - test('save token to the database using a custom connection', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - provider.setConnection(redis.connection('localDb1')) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ days: 2 }), - }) - - assert.exists(tokenId) - const tokenRow = JSON.parse((await redis.connection('localDb1').get(`api_token:${tokenId}`))!) - assert.deepEqual(tokenRow, { - user_id: '1', - name: 'Auth token', - token, - }) - - let expiry = await redis.connection('localDb1').ttl(tokenId) - assert.isBelow(expiry, 2 * 24 * 3600 + 1) - - await redis.quitAll() - }) - - test('read token from the database', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ days: 2 }), - }) - - assert.exists(tokenId) - const tokenRow = await provider.read(tokenId, token, 'api_token') - assert.equal(tokenRow!.name, 'Auth token') - assert.equal(tokenRow!.tokenHash, token) - assert.equal(tokenRow!.type, 'api_token') - }) - - test('read token from a custom database connection', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - provider.setConnection(redis.connection('localDb1')) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ days: 2 }), - }) - - assert.exists(tokenId) - const tokenRow = await provider.read(tokenId, token, 'api_token') - assert.equal(tokenRow!.name, 'Auth token') - assert.equal(tokenRow!.tokenHash, token) - assert.equal(tokenRow!.type, 'api_token') - - await redis.quitAll() - }) - - test('return null when there is a token hash mis-match', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ minutes: 30 }), - }) - - assert.isNull(await provider.read(tokenId, 'foo', 'api_token')) - }) - - test('return null when token has been expired', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ seconds: 1 }), - }) - - await sleep(2000) - assert.isNull(await provider.read(tokenId, token, 'api_token')) - }).timeout(3000) - - test('work fine when token has no expiry', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - }) - - await sleep(2000) - assert.isNotNull(await provider.read(tokenId, token, 'api_token')) - - let expiry = await redis.ttl(`api_token:${tokenId}`) - assert.equal(expiry, -1) - }).timeout(3000) - - test('return null when token is missing', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ seconds: 1 }), - }) - - assert.isNull(await provider.read(tokenId + 1, token, 'api_token')) - }) - - test('delete token from the database', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ seconds: 1 }), - }) - - await provider.destroy(tokenId, 'api_token') - const tokenRow = await redis.get(tokenId) - assert.isNull(tokenRow) - }) - - test('delete token using a custom connection', async ({ assert }) => { - const token = string.generateRandom(40) - const redis = app.container.use('Adonis/Addons/Redis') - const provider = getTokensRedisProvider(redis) - provider.setConnection(redis.connection('localDb1')) - - const tokenId = await provider.write({ - name: 'Auth token', - tokenHash: token, - userId: '1', - type: 'api_token', - expiresAt: DateTime.local().plus({ seconds: 1 }), - }) - - await provider.destroy(tokenId, 'api_token') - const tokenRow = await redis.connection('localDb1').get(tokenId) - assert.isNull(tokenRow) - - await redis.quitAll() - }) -}) diff --git a/test/user-providers/database.spec.ts b/test/user-providers/database.spec.ts deleted file mode 100644 index 9b18988..0000000 --- a/test/user-providers/database.spec.ts +++ /dev/null @@ -1,295 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' -import { DatabaseUser } from '../../src/UserProviders/Database/User' -import { setupApplication, setup, reset, cleanup, getDatabaseProvider } from '../../test-helpers' - -let app: ApplicationContract - -test.group('Database Provider | findById', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('find a user using the id', async ({ assert }) => { - assert.plan(5) - - const db = app.container.use('Adonis/Lucid/Database') - await db.table('users').insert({ username: 'virk', email: 'virk@adonisjs.com' }) - - const dbProvider = getDatabaseProvider(app, {}) - - dbProvider.before('findUser', async (query) => assert.exists(query)) - dbProvider.after('findUser', async (user) => { - assert.equal(user.username, 'virk') - assert.equal(user.email, 'virk@adonisjs.com') - }) - - const providerUser = await dbProvider.findById('1') - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - }) - - test('return null when unable to find the user', async ({ assert }) => { - assert.plan(2) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.before('findUser', async (query) => assert.exists(query)) - dbProvider.after('findUser', async () => { - throw new Error('not expected to be called') - }) - - const providerUser = await dbProvider.findById('1') - assert.isNull(providerUser.user) - }) - - test('use custom connection', async ({ assert }) => { - const db = app.container.use('Adonis/Lucid/Database') - await db.table('users').insert({ username: 'virk', email: 'virk@adonisjs.com' }) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.setConnection('secondary') - - const providerUser = await dbProvider.findById('1') - assert.isNull(providerUser.user) - }) - - test('use custom query client', async ({ assert }) => { - const db = app.container.use('Adonis/Lucid/Database') - - await db.table('users').insert({ username: 'virk', email: 'virk@adonisjs.com' }) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.setConnection(db.connection('secondary')) - - const providerUser = await dbProvider.findById('1') - assert.isNull(providerUser.user) - }) - - test('user custom user builder', async ({ assert }) => { - assert.plan(6) - - const db = app.container.use('Adonis/Lucid/Database') - await db.table('users').insert({ username: 'virk', email: 'virk@adonisjs.com' }) - - class CustomUser extends DatabaseUser {} - - const dbProvider = getDatabaseProvider(app, { - user: async () => CustomUser, - }) - - dbProvider.before('findUser', async (query) => assert.exists(query)) - dbProvider.after('findUser', async (user) => { - assert.equal(user.username, 'virk') - assert.equal(user.email, 'virk@adonisjs.com') - }) - - const providerUser = await dbProvider.findById('1') - assert.instanceOf(providerUser, CustomUser) - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - }) -}) - -test.group('Database Provider | findByUids', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('find a user using one of the uids', async ({ assert }) => { - assert.plan(10) - - await app.container - .use('Adonis/Lucid/Database') - .table('users') - .insert({ username: 'virk', email: 'virk@adonisjs.com' }) - - await app.container - .use('Adonis/Lucid/Database') - .table('users') - .insert({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const dbProvider = getDatabaseProvider(app, {}) - - dbProvider.before('findUser', async (query) => assert.exists(query)) - dbProvider.after('findUser', async (user) => { - assert.property(user, 'username') - assert.property(user, 'email') - }) - - const providerUser = await dbProvider.findByUid('virk') - const providerUser1 = await dbProvider.findByUid('nikk@adonisjs.com') - - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - assert.equal(providerUser1.user!.username, 'nikk') - assert.equal(providerUser1.user!.email, 'nikk@adonisjs.com') - }) - - test('return null when unable to lookup user using uids', async ({ assert }) => { - assert.plan(4) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.before('findUser', async (query) => assert.exists(query)) - dbProvider.after('findUser', async () => { - throw new Error('not expected to be called') - }) - - const providerUser = await dbProvider.findByUid('virk') - const providerUser1 = await dbProvider.findByUid('virk@adonisjs.com') - - assert.isNull(providerUser.user) - assert.isNull(providerUser1.user) - }) - - test('use custom connection', async ({ assert }) => { - await app.container - .use('Adonis/Lucid/Database') - .table('users') - .insert({ username: 'virk', email: 'virk@adonisjs.com' }) - await app.container - .use('Adonis/Lucid/Database') - .table('users') - .insert({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.setConnection('secondary') - - const providerUser = await dbProvider.findByUid('virk') - const providerUser1 = await dbProvider.findByUid('nikk@adonisjs.com') - - assert.isNull(providerUser.user) - assert.isNull(providerUser1.user) - }) - - test('use custom query client', async ({ assert }) => { - const db = app.container.use('Adonis/Lucid/Database') - - await db.table('users').insert({ username: 'virk', email: 'virk@adonisjs.com' }) - await db.table('users').insert({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.setConnection(db.connection('secondary')) - - const providerUser = await dbProvider.findByUid('virk') - const providerUser1 = await dbProvider.findByUid('nikk@adonisjs.com') - - assert.isNull(providerUser.user) - assert.isNull(providerUser1.user) - }) -}) - -test.group('Database Provider | findByRememberMeToken', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('find a user using a token', async ({ assert }) => { - assert.plan(5) - const db = app.container.use('Adonis/Lucid/Database') - - await db - .table('users') - .insert({ username: 'virk', email: 'virk@adonisjs.com', remember_me_token: '123' }) - await db - .table('users') - .insert({ username: 'nikk', email: 'nikk@adonisjs.com', remember_me_token: '123' }) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.before('findUser', async (query) => assert.exists(query)) - dbProvider.after('findUser', async (user) => { - assert.equal(user.username, 'virk') - assert.equal(user.email, 'virk@adonisjs.com') - }) - - const providerUser = await dbProvider.findByRememberMeToken(1, '123') - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - }) - - test('return null when user exists but token is missing', async ({ assert }) => { - const db = app.container.use('Adonis/Lucid/Database') - - await db.table('users').insert({ username: 'virk', email: 'virk@adonisjs.com' }) - await db - .table('users') - .insert({ username: 'nikk', email: 'nikk@adonisjs.com', remember_me_token: '123' }) - - const dbProvider = getDatabaseProvider(app, {}) - const providerUser = await dbProvider.findByRememberMeToken(1, '123') - assert.isNull(providerUser.user) - }) - - test('return null when user is missing', async ({ assert }) => { - const dbProvider = getDatabaseProvider(app, {}) - const providerUser = await dbProvider.findByRememberMeToken(1, '123') - assert.isNull(providerUser.user) - }) - - test('use custom connection', async ({ assert }) => { - const db = app.container.use('Adonis/Lucid/Database') - - await db - .table('users') - .insert({ username: 'virk', email: 'virk@adonisjs.com', remember_me_token: '123' }) - await db - .table('users') - .insert({ username: 'nikk', email: 'nikk@adonisjs.com', remember_me_token: '123' }) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.setConnection('secondary') - const providerUser = await dbProvider.findByRememberMeToken(1, '123') - assert.isNull(providerUser.user) - }) - - test('use custom query client', async ({ assert }) => { - const db = app.container.use('Adonis/Lucid/Database') - - await db - .table('users') - .insert({ username: 'virk', email: 'virk@adonisjs.com', remember_me_token: '123' }) - await db - .table('users') - .insert({ username: 'nikk', email: 'nikk@adonisjs.com', remember_me_token: '123' }) - - const dbProvider = getDatabaseProvider(app, {}) - dbProvider.setConnection(db.connection('secondary')) - const providerUser = await dbProvider.findByRememberMeToken(1, '123') - assert.isNull(providerUser.user) - }) -}) diff --git a/test/user-providers/lucid.spec.ts b/test/user-providers/lucid.spec.ts deleted file mode 100644 index 62d0853..0000000 --- a/test/user-providers/lucid.spec.ts +++ /dev/null @@ -1,316 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { LucidUser } from '../../src/UserProviders/Lucid/User' -import { - setup, - reset, - cleanup, - getUserModel, - getLucidProvider, - setupApplication, -} from '../../test-helpers' - -let app: ApplicationContract - -test.group('Lucid Provider | findById', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('find a user using the id', async ({ assert }) => { - assert.plan(5) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async (model) => assert.instanceOf(model, User)) - - const providerUser = await lucidProvider.findById(user.id) - - assert.instanceOf(providerUser.user, User) - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - }) - - test('return null when unable to lookup using id', async ({ assert }) => { - assert.plan(2) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async () => { - throw new Error('not expected to be invoked') - }) - - const providerUser = await lucidProvider.findById(1) - assert.isNull(providerUser.user) - }) - - test('use custom connection', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.setConnection('secondary') - - const providerUser = await lucidProvider.findById(user.id) - assert.isNull(providerUser.user) - }) - - test('use custom query client', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.setConnection(app.container.use('Adonis/Lucid/Database').connection('secondary')) - - const providerUser = await lucidProvider.findById(user.id) - assert.isNull(providerUser.user) - }) - - test('use custom user builder', async ({ assert }) => { - assert.plan(6) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ username: 'virk', email: 'virk@adonisjs.com' }) - class CustomUser extends LucidUser {} - - const lucidProvider = getLucidProvider(app, { - model: async () => User, - user: async () => CustomUser, - }) - - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async (model) => assert.instanceOf(model, User)) - - const providerUser = await lucidProvider.findById(user.id) - - assert.instanceOf(providerUser, CustomUser) - assert.instanceOf(providerUser.user, User) - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - }) -}) - -test.group('Lucid Provider | findByUids', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('find a user using one of the uids', async ({ assert }) => { - assert.plan(9) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - await User.create({ username: 'virk', email: 'virk@adonisjs.com' }) - await User.create({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async (user) => assert.instanceOf(user, User)) - - const providerUser = await lucidProvider.findByUid('virk') - const providerUser1 = await lucidProvider.findByUid('nikk@adonisjs.com') - - assert.instanceOf(providerUser.user, User) - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - - assert.equal(providerUser1.user!.username, 'nikk') - assert.equal(providerUser1.user!.email, 'nikk@adonisjs.com') - }) - - test('return null when unable to lookup user using uid', async ({ assert }) => { - assert.plan(4) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async () => { - throw new Error('not expected to be invoked') - }) - - const providerUser = await lucidProvider.findByUid('virk') - const providerUser1 = await lucidProvider.findByUid('virk@adonisjs.com') - assert.isNull(providerUser.user) - assert.isNull(providerUser1.user) - }) - - test('use custom connection', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - await User.create({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.setConnection('secondary') - - const providerUser = await lucidProvider.findByUid('nikk') - const providerUser1 = await lucidProvider.findByUid('nikk@adonisjs.com') - - assert.isNull(providerUser.user) - assert.isNull(providerUser1.user) - }) - - test('use custom query client', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - await User.create({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.setConnection(app.container.use('Adonis/Lucid/Database').connection('secondary')) - - const providerUser = await lucidProvider.findByUid('nikk') - const providerUser1 = await lucidProvider.findByUid('nikk@adonisjs.com') - assert.isNull(providerUser.user) - assert.isNull(providerUser1.user) - }) - - test('find a user using the custom function', async ({ assert }) => { - assert.plan(4) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - User['findForAuth'] = function (_: any, uid: string) { - return this.query().where('username', uid).first() - } - - await User.create({ username: 'virk', email: 'virk@adonisjs.com' }) - await User.create({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - - /** - * These won't be executed - */ - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async (user) => assert.instanceOf(user, User)) - - const providerUser = await lucidProvider.findByUid('virk') - const providerUser1 = await lucidProvider.findByUid('nikk@adonisjs.com') - - assert.instanceOf(providerUser.user, User) - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - - assert.isNull(providerUser1.user) - }) -}) - -test.group('Lucid Provider | findByRememberMeToken', (group) => { - group.setup(async () => { - app = await setupApplication() - await setup(app) - }) - - group.teardown(async () => { - await cleanup(app) - }) - - group.each.teardown(async () => { - await reset(app) - }) - - test('find a user using a token', async ({ assert }) => { - assert.plan(5) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - rememberMeToken: '123', - }) - await User.create({ username: 'nikk', email: 'nikk@adonisjs.com' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async (model) => assert.instanceOf(model, User)) - - const providerUser = await lucidProvider.findByRememberMeToken(user.id, '123') - assert.instanceOf(providerUser.user, User) - assert.equal(providerUser.user!.username, 'virk') - assert.equal(providerUser.user!.email, 'virk@adonisjs.com') - }) - - test("return null when user doesn't exists", async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const lucidProvider = getLucidProvider(app, { model: async () => User }) - const providerUser = await lucidProvider.findByRememberMeToken(1, '123') - assert.isNull(providerUser.user) - }) - - test('return null when users exists but token is missing', async ({ assert }) => { - assert.plan(2) - - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - - const user = await User.create({ username: 'nikk', email: 'nikk@adonisjs.com' }) - await User.create({ username: 'virk', email: 'virk@adonisjs.com', rememberMeToken: '123' }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.before('findUser', async (query) => assert.exists(query)) - lucidProvider.after('findUser', async () => { - throw new Error('not expected to be invoked') - }) - - const providerUser = await lucidProvider.findByRememberMeToken(user.id, '123') - assert.isNull(providerUser.user) - }) - - test('use custom connection', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - rememberMeToken: '123', - }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.setConnection('secondary') - - const providerUser = await lucidProvider.findByRememberMeToken(user.id, '123') - assert.isNull(providerUser.user) - }) - - test('use custom query client', async ({ assert }) => { - const User = getUserModel(app.container.use('Adonis/Lucid/Orm').BaseModel) - const user = await User.create({ - username: 'virk', - email: 'virk@adonisjs.com', - rememberMeToken: '123', - }) - - const lucidProvider = getLucidProvider(app, { model: async () => User }) - lucidProvider.setConnection(app.container.use('Adonis/Lucid/Database').connection('secondary')) - - const providerUser = await lucidProvider.findByRememberMeToken(user.id, '123') - assert.isNull(providerUser.user) - }) -}) diff --git a/tsconfig.json b/tsconfig.json index 5f86a99..2039043 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,7 @@ { - "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", - "files": [ - "./node_modules/@adonisjs/lucid/build/adonis-typings/index.d.ts", - "./node_modules/@adonisjs/core/build/adonis-typings/index.d.ts", - "./node_modules/@adonisjs/application/build/adonis-typings/index.d.ts", - "./node_modules/@adonisjs/session/build/adonis-typings/index.d.ts", - "./node_modules/@adonisjs/redis/build/adonis-typings/index.d.ts", - "./node_modules/@adonisjs/i18n/build/adonis-typings/index.d.ts", - "./node_modules/@japa/preset-adonis/build/adonis-typings/index.d.ts" - ], + "extends": "@adonisjs/tsconfig/tsconfig.package.json", "compilerOptions": { - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "skipLibCheck": true + "rootDir": "./", + "outDir": "./build" } -} +} \ No newline at end of file From c12823d6d83334f1d781b5bd30155e23973deba3 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sat, 7 Oct 2023 15:04:23 +0530 Subject: [PATCH 02/96] chore: update dot ignore files --- .gitignore | 2 +- .npmrc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e97f27e..324fb8c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ coverage *.log build dist -package-lock.json yarn.lock shrinkwrap.yaml +package-lock.json test/__app diff --git a/.npmrc b/.npmrc index a54c771..43c97e7 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -message="chore(release): %s" +package-lock=false From f47a5ab111630891a6d2671c09d0c0b8672a5965 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 11 Oct 2023 17:24:08 +0530 Subject: [PATCH 03/96] feat: implement core pieces of auth --- .github/stale.yml | 4 +- bin/test.ts | 22 ++ factories/database_token_factory.ts | 79 +++++++ factories/database_user_provider.ts | 32 +++ factories/lucid_user_provider.ts | 80 +++++++ factories/main.ts | 21 ++ factories/session_guard_factory.ts | 44 ++++ package.json | 22 +- src/authenticator.ts | 67 ++++++ src/core/README.md | 4 + src/core/guard_user.ts | 42 ++++ src/core/symbols.ts | 14 ++ src/core/token.ts | 124 ++++++++++ src/core/token_providers/database.ts | 158 +++++++++++++ src/core/types.ts | 223 ++++++++++++++++++ src/core/user_providers/database.ts | 156 ++++++++++++ src/core/user_providers/lucid.ts | 164 +++++++++++++ src/debug.ts | 12 + src/errors.ts | 19 ++ src/session/define_session_guard.ts | 10 + src/session/guard.ts | 140 +++++++++++ src/session/remember_me_token.ts | 65 +++++ src/session/types.ts | 119 ++++++++++ tests/core/token.spec.ts | 59 +++++ tests/core/token_providers/database.spec.ts | 126 ++++++++++ .../database/create_user_for_guard.spec.ts | 43 ++++ .../database/find_by_id.spec.ts | 44 ++++ .../database/find_by_uid.spec.ts | 54 +++++ .../database/guard_user.spec.ts | 56 +++++ .../lucid/create_user_for_guard.spec.ts | 41 ++++ .../user_providers/lucid/find_by_id.spec.ts | 39 +++ .../user_providers/lucid/find_by_uid.spec.ts | 45 ++++ .../user_providers/lucid/guard_user.spec.ts | 41 ++++ tests/helpers.ts | 94 ++++++++ tests/modules/session/guard.spec.ts | 34 +++ tsconfig.json | 2 +- 36 files changed, 2293 insertions(+), 6 deletions(-) create mode 100644 bin/test.ts create mode 100644 factories/database_token_factory.ts create mode 100644 factories/database_user_provider.ts create mode 100644 factories/lucid_user_provider.ts create mode 100644 factories/main.ts create mode 100644 factories/session_guard_factory.ts create mode 100644 src/authenticator.ts create mode 100644 src/core/README.md create mode 100644 src/core/guard_user.ts create mode 100644 src/core/symbols.ts create mode 100644 src/core/token.ts create mode 100644 src/core/token_providers/database.ts create mode 100644 src/core/types.ts create mode 100644 src/core/user_providers/database.ts create mode 100644 src/core/user_providers/lucid.ts create mode 100644 src/debug.ts create mode 100644 src/errors.ts create mode 100644 src/session/define_session_guard.ts create mode 100644 src/session/guard.ts create mode 100644 src/session/remember_me_token.ts create mode 100644 src/session/types.ts create mode 100644 tests/core/token.spec.ts create mode 100644 tests/core/token_providers/database.spec.ts create mode 100644 tests/core/user_providers/database/create_user_for_guard.spec.ts create mode 100644 tests/core/user_providers/database/find_by_id.spec.ts create mode 100644 tests/core/user_providers/database/find_by_uid.spec.ts create mode 100644 tests/core/user_providers/database/guard_user.spec.ts create mode 100644 tests/core/user_providers/lucid/create_user_for_guard.spec.ts create mode 100644 tests/core/user_providers/lucid/find_by_id.spec.ts create mode 100644 tests/core/user_providers/lucid/find_by_uid.spec.ts create mode 100644 tests/core/user_providers/lucid/guard_user.spec.ts create mode 100644 tests/helpers.ts create mode 100644 tests/modules/session/guard.spec.ts diff --git a/.github/stale.yml b/.github/stale.yml index 7a6a571..f767674 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -6,10 +6,10 @@ daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - - "Type: Security" + - 'Type: Security' # Label to use when marking an issue as stale -staleLabel: "Status: Abandoned" +staleLabel: 'Status: Abandoned' # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > diff --git a/bin/test.ts b/bin/test.ts new file mode 100644 index 0000000..542479b --- /dev/null +++ b/bin/test.ts @@ -0,0 +1,22 @@ +import { assert } from '@japa/assert' +import { snapshot } from '@japa/snapshot' +import { fileSystem } from '@japa/file-system' +import { expectTypeOf } from '@japa/expect-type' +import { configure, processCLIArgs, run } from '@japa/runner' + +processCLIArgs(process.argv.splice(2)) +configure({ + suites: [ + { + name: 'session', + files: ['tests/modules/session/**/*.spec.ts'], + }, + { + name: 'core', + files: ['tests/core/**/*.spec.ts'], + }, + ], + plugins: [assert(), fileSystem(), expectTypeOf(), snapshot()], +}) + +run() diff --git a/factories/database_token_factory.ts b/factories/database_token_factory.ts new file mode 100644 index 0000000..33962ca --- /dev/null +++ b/factories/database_token_factory.ts @@ -0,0 +1,79 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Database } from '@adonisjs/lucid/database' +import { Token } from '../src/core/token.js' +import { DatabaseTokenProvider } from '../src/core/token_providers/database.js' + +/** + * Representation of token used for testing + */ +export class TestToken extends Token { + type = 'test_token' + + declare userId: string | number + + static create(userId: number | string, expiry: string | number, size?: number): TestToken { + const { series, value, hash } = this.seed(size) + const token = new TestToken(series, value, hash) + token.setExpiry(expiry) + token.userId = userId + + return token + } +} + +/** + * Test implementation of the database token provider + */ +export class TestDatabaseTokenProvider extends DatabaseTokenProvider { + protected prepareToken(dbRow: { + series: string + user_id: string | number + type: string + token: string + created_at: Date + expires_at: Date | null + }): TestToken { + const token = new TestToken(dbRow.series, undefined, dbRow.token) + token.createdAt = dbRow.created_at + if (dbRow.expires_at) { + token.expiresAt = dbRow.expires_at + } + return token + } + + protected parseToken(token: TestToken): { + series: string + user_id: string | number + type: string + token: string + created_at: Date + updated_at: Date + expires_at: Date | null + } { + return { + series: token.series, + user_id: token.userId, + type: token.type, + token: token.hash, + created_at: token.createdAt, + updated_at: token.createdAt, + expires_at: token.expiresAt || null, + } + } +} + +export class DatabaseTokenProviderFactory { + create(db: Database) { + return new TestDatabaseTokenProvider(db, { + table: 'remember_me_tokens', + }) + } +} diff --git a/factories/database_user_provider.ts b/factories/database_user_provider.ts new file mode 100644 index 0000000..e18f582 --- /dev/null +++ b/factories/database_user_provider.ts @@ -0,0 +1,32 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Hash } from '@adonisjs/core/hash' +import type { Database } from '@adonisjs/lucid/database' +import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' +import { DatabaseUserProvider } from '../src/core/user_providers/database.js' + +export class TestDatabaseUserProvider< + RealUser extends Record, +> extends DatabaseUserProvider {} + +/** + * Creates an instance of the DatabaseUserProvider with sane + * defaults for testing + */ +export class DatabaseUserProviderFactory { + create(db: Database) { + return new TestDatabaseUserProvider(db, new Hash(new Scrypt({})), { + id: 'id', + table: 'users', + passwordColumnName: 'password', + uids: ['email', 'username'], + }) + } +} diff --git a/factories/lucid_user_provider.ts b/factories/lucid_user_provider.ts new file mode 100644 index 0000000..f89b26d --- /dev/null +++ b/factories/lucid_user_provider.ts @@ -0,0 +1,80 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Hash } from '@adonisjs/core/hash' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' +import { LucidUserProvider } from '../src/core/user_providers/lucid.js' +import { LucidAuthenticatable, LucidUserProviderOptions } from '../src/core/types.js' +import { PROVIDER_REAL_USER } from '../src/core/symbols.js' + +export class FactoryUser extends BaseModel { + static table = 'users' + + static createWithDefaults(attributes?: { + email?: string + password?: string | null + username?: string + }) { + return this.create({ + email: 'foo@bar.com', + username: 'foo', + password: 'secret', + ...attributes, + }) + } + + @column() + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string | null + + async verifyPasswordForAuth(plainTextPassword: string) { + return new Hash(new Scrypt({})).verify(this.password!, plainTextPassword) + } +} + +export class TestLucidUserProvider< + UserModel extends LucidAuthenticatable, +> extends LucidUserProvider { + declare [PROVIDER_REAL_USER]: InstanceType +} + +/** + * Creates an instance of the LucidUserProvider with sane + * defaults for testing + */ +export class LucidUserProviderFactory { + createForModel( + model: Model, + options: LucidUserProviderOptions + ) { + return new TestLucidUserProvider( + async () => { + return { + default: model, + } + }, + { + ...options, + } + ) + } + + create() { + return this.createForModel(FactoryUser, { uids: ['email', 'username'] }) + } +} diff --git a/factories/main.ts b/factories/main.ts new file mode 100644 index 0000000..d7fcf68 --- /dev/null +++ b/factories/main.ts @@ -0,0 +1,21 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export { SessionGuardFactory } from './session_guard_factory.js' +export { DatabaseUserProviderFactory, TestDatabaseUserProvider } from './database_user_provider.js' +export { + FactoryUser, + LucidUserProviderFactory, + TestLucidUserProvider, +} from './lucid_user_provider.js' +export { + TestToken, + TestDatabaseTokenProvider, + DatabaseTokenProviderFactory, +} from './database_token_factory.js' diff --git a/factories/session_guard_factory.ts b/factories/session_guard_factory.ts new file mode 100644 index 0000000..de4d632 --- /dev/null +++ b/factories/session_guard_factory.ts @@ -0,0 +1,44 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' + +import { SessionGuard } from '../src/session/guard.js' +import type { SessionGuardConfig, SessionUserProviderContract } from '../src/session/types.js' +import { + FactoryUser, + TestLucidUserProvider, + LucidUserProviderFactory, +} from './lucid_user_provider.js' + +/** + * Exposes the API to create a session guard for testing. Under + * the hood configures Lucid models for looking up users + */ +export class SessionGuardFactory { + #config: SessionGuardConfig = { rememberMeTokenAge: '5y' } + + merge(config: SessionGuardConfig) { + this.#config = config + return this + } + + create< + UserProvider extends SessionUserProviderContract = TestLucidUserProvider< + typeof FactoryUser + >, + >(ctx: HttpContext, provider?: UserProvider) { + return new SessionGuard( + 'web', + this.#config, + ctx, + provider || new LucidUserProviderFactory().create() + ) + } +} diff --git a/package.json b/package.json index c1f632b..7c4d155 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ }, "scripts": { "pretest": "npm run lint", - "test": "c8 npm run vscode:test", - "quick:test": "node --enable-source-maps --loader=ts-node/esm ./bin/test.js", + "test": "c8 npm run quick:test", + "quick:test": "cross-env NODE_DEBUG=\"adonisjs:auth\" node --enable-source-maps --loader=ts-node/esm ./bin/test.js", "clean": "del-cli build", "copy:templates": "copyfiles \"stubs/**/**/*.stub\" build", "compile": "npm run lint && npm run clean && tsc && npm run copy:templates", @@ -59,21 +59,33 @@ "url": "https://github.com/adonisjs/auth/issues" }, "devDependencies": { + "@adonisjs/core": "^6.1.5-26", "@adonisjs/eslint-config": "^1.1.8", + "@adonisjs/lucid": "^19.0.0-2", "@adonisjs/prettier-config": "^1.1.8", + "@adonisjs/session": "^7.0.0-11", "@adonisjs/tsconfig": "^1.1.8", "@commitlint/cli": "^17.7.2", "@commitlint/config-conventional": "^17.7.0", + "@japa/assert": "^2.0.0-2", + "@japa/expect-type": "^2.0.0-1", + "@japa/file-system": "^2.0.0-2", + "@japa/runner": "^3.0.1", + "@japa/snapshot": "^2.0.0", "@swc/core": "1.3.82", + "@types/luxon": "^3.3.2", "@types/node": "^20.8.3", "c8": "^8.0.1", "copyfiles": "^2.4.1", + "cross-env": "^7.0.3", "del-cli": "^5.1.0", "eslint": "^8.25.0", "github-label-sync": "^2.3.1", "husky": "^8.0.3", + "luxon": "^3.4.3", "np": "^8.0.4", "prettier": "^3.0.3", + "sqlite3": "^5.1.6", "ts-node": "^10.9.1", "typescript": "^5.2.2" }, @@ -102,7 +114,11 @@ "html" ], "exclude": [ - "tests/**" + "tests/**", + "factories/**" ] + }, + "dependencies": { + "@poppinss/utils": "^6.5.0-7" } } diff --git a/src/authenticator.ts b/src/authenticator.ts new file mode 100644 index 0000000..45016cc --- /dev/null +++ b/src/authenticator.ts @@ -0,0 +1,67 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { HttpContext } from '@adonisjs/core/http' +import debug from './debug.js' + +/** + * Authenticator is an HTTP request specific implementation for using + * guards to login users and authenticate requests. + */ +export class Authenticator unknown>> { + /** + * Reference to HTTP context + */ + #ctx: HttpContext + + /** + * Registered guards + */ + #config: { + default?: keyof KnownGuards + guards: KnownGuards + } + + /** + * Cache of guards created during the HTTP request + */ + #guardsCache: Partial> = {} + + constructor(ctx: HttpContext, config: { default?: keyof KnownGuards; guards: KnownGuards }) { + this.#ctx = ctx + this.#config = config + debug('creating authenticator. config %O', this.#config) + } + + /** + * Returns an instance of a known guard. Guards instances are + * cached during the lifecycle of an HTTP request. + */ + use(guard: Guard): ReturnType { + /** + * Use cached copy if exists + */ + const cachedGuard = this.#guardsCache[guard] + if (cachedGuard) { + debug('using guard from cache. name: "%s"', guard) + return cachedGuard as ReturnType + } + + const guardFactory = this.#config.guards[guard] + + /** + * Construct guard and cache it + */ + debug('creating guard. name: "%s"', guard) + const guardInstance = guardFactory(this.#ctx) + this.#guardsCache[guard] = guardInstance + + return guardInstance as ReturnType + } +} diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 0000000..fee2881 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,4 @@ +# Authentication core +The core part of the codebase provides base implementations that can be used by the first and third party guards and providers. + +These base implementations must not be used inside the user-land code and the main purpose is to provide ready to use abstractions for guards and providers. diff --git a/src/core/guard_user.ts b/src/core/guard_user.ts new file mode 100644 index 0000000..f92142a --- /dev/null +++ b/src/core/guard_user.ts @@ -0,0 +1,42 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * Guard user represents a user independent of the storage + * provider. It contains a standard set of properties + * used by authentication guards to interact with + * a user. + * + * Think of it as a bridge between a user and the authentication + * guard. + */ +export abstract class GuardUser { + protected realUser: RealUser + constructor(realUser: RealUser) { + this.realUser = realUser + } + + /** + * Verifies the plain text password against the user password + * hash + */ + abstract verifyPassword(plainTextPassword: string): Promise + + /** + * Returns a value to uniquely identify the user. + */ + abstract getId(): number | string + + /** + * Returns the original provider specific user object. + */ + getOriginal(): RealUser { + return this.realUser + } +} diff --git a/src/core/symbols.ts b/src/core/symbols.ts new file mode 100644 index 0000000..29f7c26 --- /dev/null +++ b/src/core/symbols.ts @@ -0,0 +1,14 @@ +/* + * @adonisjs/lucid + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * A symbol to identify the type of the real user for a given + * user provider + */ +export const PROVIDER_REAL_USER = Symbol.for('PROVIDER_REAL_USER') diff --git a/src/core/token.ts b/src/core/token.ts new file mode 100644 index 0000000..f8a8b35 --- /dev/null +++ b/src/core/token.ts @@ -0,0 +1,124 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHash } from 'node:crypto' +import { base64, safeEqual } from '@adonisjs/core/helpers' +import string from '@adonisjs/core/helpers/string' + +import * as errors from '../errors.js' +import type { TokenContract } from './types.js' + +/** + * A token represents an opaque token issued to a client + * to perform a specific task. + * + * The raw value of a token is only visible at the time of + * issuing it and one must persist hash to the database. + */ +export abstract class Token implements TokenContract { + /** + * Token type to uniquely identify a bucket of tokens + */ + abstract readonly type: string + + /** + * Arbitary meta-data associated with the token + */ + metaData?: Record + + /** + * Timestamp when the token will expire + */ + expiresAt?: Date + + /** + * Date/time when the token instance was created + */ + createdAt: Date = new Date() + + constructor( + /** + * Series is a random number stored inside the database as it is + */ + public series: string, + + /** + * Value is a random number only available at the time of issuing + * the token. Afterwards, the value is undefined. + */ + public value: string | undefined, + + /** + * Hash reference to the token hash + */ + public hash: string + ) {} + + /** + * Define metadata for the token + */ + setMetaData(metaData: Record): this { + this.metaData = metaData + return this + } + + /** + * Verifies the value of a token against the pre-defined hash + */ + verify(value: string) { + const newHash = createHash('sha256').update(value).digest('hex') + return safeEqual(this.hash, newHash) + } + + /** + * Define the token expiresAt timestamp from a duration. The value + * value must be a number in seconds or a string expression. + */ + setExpiry(duration: string | number) { + /** + * Defining a date object and adding seconds since the + * creation of the token + */ + this.expiresAt = new Date() + this.expiresAt.setSeconds(this.createdAt.getSeconds() + string.seconds.parse(duration)) + } + + /** + * Creates token value, series, and hash + */ + static seed(size: number = 30) { + const series = string.random(15) + const value = string.random(size) + const hash = createHash('sha256').update(value).digest('hex') + + return { series, value: `${base64.urlEncode(series)}.${base64.urlEncode(value)}`, hash } + } + + /** + * Decodes a publicly shared token and return the series + * and the token value from it. + */ + static decode(value: string) { + const [series, ...tokenValue] = value.split('.') + if (!series || tokenValue.length === 0) { + throw new errors.E_INVALID_AUTH_TOKEN() + } + + const decodedSeries = base64.urlDecode(series) + const decodedValue = base64.urlDecode(tokenValue.join('.')) + if (!decodedSeries || !decodedValue) { + throw new errors.E_INVALID_AUTH_TOKEN() + } + + return { + series: decodedSeries, + value: decodedValue, + } + } +} diff --git a/src/core/token_providers/database.ts b/src/core/token_providers/database.ts new file mode 100644 index 0000000..1255da5 --- /dev/null +++ b/src/core/token_providers/database.ts @@ -0,0 +1,158 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Database } from '@adonisjs/lucid/database' + +import debug from '../../debug.js' +import type { DatabaseTokenProviderOptions, TokenProviderContract } from '../types.js' + +/** + * The representation of a token inside the database + */ +type DatabaseTokenRow = { + series: string + user_id: string | number + type: string + token: string + created_at: Date + updated_at: Date + expires_at: Date | null +} + +/** + * A generic implementation to read tokens from the database + */ +export abstract class DatabaseTokenProvider implements TokenProviderContract { + constructor( + /** + * Reference to the database query builder needed to + * query the database for tokens + */ + protected db: Database, + + /** + * Options accepted + */ + protected options: DatabaseTokenProviderOptions + ) { + debug('db_token_provider: options %O', options) + } + + /** + * Should parse token to a database token row + */ + protected abstract parseToken(token: Token): DatabaseTokenRow + + /** + * Abstract method to prepare a token from the database + * row + */ + protected abstract prepareToken(dbRow: DatabaseTokenRow): Token + + /** + * Returns an instance of the query builder + */ + protected getQueryBuilder() { + return this.options.client + ? this.options.client.query() + : this.db.connection(this.options.connection).query() + } + + /** + * Returns an instance of the query builder for insert + * queries + */ + protected getInsertQueryBuilder() { + return this.options.client + ? this.options.client.insertQuery() + : this.db.connection(this.options.connection).insertQuery() + } + + /** + * Persists token inside the database + */ + async createToken(token: Token): Promise { + const parsedToken = this.parseToken(token) + debug('db_token_provider: creating token %O', parsedToken) + + await this.getInsertQueryBuilder() + .table(this.options.table) + .insert({ + ...parsedToken, + }) + } + + /** + * Finds a token by series inside the database and returns an + * instance of it. + * + * Returns null if the token is missing or expired + */ + async getTokenBySeries(series: string): Promise { + debug('db_token_provider: reading token by series %s', series) + const token = await this.getQueryBuilder() + .from(this.options.table) + .where('series', series) + .limit(1) + .first() + + if (!token) { + debug('db_token_provider:: token %O', token) + return null + } + + if (typeof token.expires_at === 'number') { + token.expires_at = new Date(token.expires_at) + } + if (typeof token.created_at === 'number') { + token.created_at = new Date(token.created_at) + } + if (typeof token.updated_at === 'number') { + token.updated_at = new Date(token.updated_at) + } + + debug('db_token_provider:: token %O', token) + + /** + * Return null when token has been expired + */ + if (token.expires_at && token.expires_at instanceof Date && token.expires_at < new Date()) { + return null + } + + return this.prepareToken(token) + } + + /** + * Removes a token from the database by the + * series number + */ + async deleteTokenBySeries(series: string): Promise { + debug('db_token_provider: deleting token by series %s', series) + await this.getQueryBuilder().from(this.options.table).where('series', series).del() + } + + /** + * Updates token hash and expiry + */ + async updateTokenBySeries(series: string, hash: string, expiresAt: Date): Promise { + const updatePayload = { + token: hash, + updated_at: new Date(), + expires_at: expiresAt, + } + + debug('db_token_provider: updating token by series %s: %O', series, updatePayload) + + await this.getQueryBuilder() + .from(this.options.table) + .where('series', series) + .update(updatePayload) + } +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..75eca71 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,223 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { QueryClientContract } from '@adonisjs/lucid/types/database' + +import type { GuardUser } from './guard_user.js' +import type { PROVIDER_REAL_USER } from './symbols.js' +import { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' + +/** + * A token represents an opaque token issued to a client + * to perform a specific task. + * + * The raw value of a token is only visible at the time of + * issuing it and one must persist hash to the database. + */ +export interface TokenContract { + /** + * Token type to uniquely identify a bucket of tokens + */ + readonly type: string + + /** + * The plain text value. Only exists when the token is first + * created + */ + value?: string + + /** + * Additional metadata associated with the token. + */ + metaData?: Record + + /** + * The token hash for persisting the token in a database + */ + hash: string + + /** + * A unique readable series counter to find the token inside the + * database. + */ + series: string + + /** + * Timestamp when the token was first persisted + */ + createdAt: Date + + /** + * Timestamp when the token will expire + */ + expiresAt?: Date + + /** + * Verifies the raw text value against the hash + */ + verify(value: string): boolean +} + +/** + * The UserProvider is used to lookup a user for authentication + */ +export interface UserProviderContract { + [PROVIDER_REAL_USER]: RealUser + + /** + * Creates a user object that guards can use for + * authentication. + */ + createUserForGuard(user: RealUser): Promise> + + /** + * Find a user by uid. The uid could be one or multiple fields + * to unique identify a user. + * + * This method is called when finding a user for login + */ + findByUid(value: string | number): Promise | null> + + /** + * Find a user by unique primary id. This method is called when + * authenticating user from their session. + */ + findById(value: string | number): Promise | null> +} + +/** + * The TokenProvider is used to lookup/persist tokens during authentication + */ +export interface TokenProviderContract { + /** + * Returns a token by the series counter, or null when token is + * missing + */ + getTokenBySeries(series: string): Promise + + /** + * Deletes a token by the series counter + */ + deleteTokenBySeries(series: string): Promise + + /** + * Updates a token by the series counter + */ + updateTokenBySeries(series: string, hash: string, expiresAt: Date): Promise + + /** + * Creates a new token and persists it to the database + */ + createToken(token: Token): Promise +} + +/** + * A lucid model that can be used during authentication + */ +export type LucidAuthenticatable = LucidModel & { + new (): LucidRow & { + /** + * Verify the plain text password against the user password + * hash + */ + verifyPasswordForAuth(plainTextPassword: string): Promise + } +} + +/** + * Options accepted by the Lucid user provider + */ +export type LucidUserProviderOptions = { + /** + * Optionally define the connection to use when making database + * queries + */ + connection?: string + + /** + * Optionally define the query client instance to use for making + * database queries. + * + * When both "connection" and "client" are defined, the client will + * be given the preference. + */ + client?: QueryClientContract + + /** + * An array of uids to use when finding a user for login. Make + * sure all fields can be used to uniquely lookup a user. + */ + uids: Extract, string>[] +} + +/** + * Options accepted by the Database user provider + */ +export type DatabaseUserProviderOptions> = { + /** + * Optionally define the connection to use when making database + * queries + */ + connection?: string + + /** + * Optionally define the query client instance to use for making + * database queries. + * + * When both "connection" and "client" are defined, the client will + * be given the preference. + */ + client?: QueryClientContract + + /** + * Database table to query to find the user + */ + table: string + + /** + * Column name to read the hashed password + */ + passwordColumnName: string + + /** + * An array of uids to use when finding a user for login. Make + * sure all fields can be used to uniquely lookup a user. + */ + uids: Extract[] + + /** + * The name of the id column to unique identify the user. + */ + id: string +} + +/** + * Options accepted by the Database token provider + */ +export type DatabaseTokenProviderOptions = { + /** + * Optionally define the connection to use when making database + * queries + */ + connection?: string + + /** + * Optionally define the query client instance to use for making + * database queries. + * + * When both "connection" and "client" are defined, the client will + * be given the preference. + */ + client?: QueryClientContract + + /** + * Database table to query to find the user + */ + table: string +} diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts new file mode 100644 index 0000000..bbe0323 --- /dev/null +++ b/src/core/user_providers/database.ts @@ -0,0 +1,156 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Hash } from '@adonisjs/core/hash' +import { RuntimeException } from '@poppinss/utils' +import type { Database } from '@adonisjs/lucid/database' + +import debug from '../../debug.js' +import { GuardUser } from '../guard_user.js' +import { PROVIDER_REAL_USER } from '../symbols.js' +import type { DatabaseUserProviderOptions, UserProviderContract } from '../types.js' + +/** + * Database user represents a guard user used by authentication guards + * to perform authentication. + */ +class DatabaseUser> extends GuardUser { + #options: { id: string; passwordColumnName: string } + #hasher: Hash + + constructor( + realUser: RealUser, + hasher: Hash, + options: { id: string; passwordColumnName: string } + ) { + super(realUser) + this.#hasher = hasher + this.#options = options + } + + /** + * @inheritdoc + */ + getId(): string | number { + const id = this.realUser[this.#options.id] + + if (!id) { + throw new RuntimeException( + `Invalid user object. The value of column "${this.#options.id}" is undefined or null` + ) + } + + return id + } + + /** + * @inheritdoc + */ + async verifyPassword(plainTextPassword: string): Promise { + const password = this.realUser[this.#options.passwordColumnName] + + if (!password) { + throw new RuntimeException( + `Cannot verify password during login. The value of column "${ + this.#options.passwordColumnName + }" is undefined or null` + ) + } + + return this.#hasher.verify(password, plainTextPassword) + } +} + +/** + * Database user provider is used to lookup user for authentication + * using the Database query builder. + */ +export abstract class DatabaseUserProvider> + implements UserProviderContract +{ + declare [PROVIDER_REAL_USER]: RealUser + + constructor( + /** + * Reference to the database query builder needed to + * query the database for users + */ + protected db: Database, + + /** + * Hasher is used to verify plain text passwords + */ + protected hasher: Hash, + + /** + * Options accepted + */ + protected options: DatabaseUserProviderOptions + ) { + debug('db_user_provider: options %O', options) + } + + /** + * Returns an instance of the query builder + */ + protected getQueryBuilder() { + return this.options.client + ? this.options.client.query() + : this.db.connection(this.options.connection).query() + } + + /** + * Returns an instance of the "DatabaseUser" that guards + * can use for authentication + */ + async createUserForGuard(user: RealUser) { + if (!user || typeof user !== 'object') { + throw new RuntimeException( + `Invalid user object. It must be a database row object from the "${this.options.table}" table` + ) + } + + debug('db_user_provider: converting user object to guard user %O', user) + return new DatabaseUser(user, this.hasher, this.options) + } + + /** + * Finds a user by id by query the configured database + * table + */ + async findById(value: string | number): Promise | null> { + const query = this.getQueryBuilder().from(this.options.table) + debug('db_user_provider: finding user by id %s', value) + + const user = await query.where(this.options.id, value).limit(1).first() + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + /** + * Finds a user using one of the pre-configured unique + * ids, via the configured model. + */ + async findByUid(value: string | number): Promise | null> { + const query = this.getQueryBuilder().from(this.options.table) + this.options.uids.forEach((uid) => query.orWhere(uid, value)) + + debug('db_user_provider: finding user by uids, uids: %O, value: %s', this.options.uids, value) + + const user = await query.limit(1).first() + if (!user) { + return null + } + + return this.createUserForGuard(user) + } +} diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts new file mode 100644 index 0000000..30b1a38 --- /dev/null +++ b/src/core/user_providers/lucid.ts @@ -0,0 +1,164 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { RuntimeException } from '@poppinss/utils' + +import debug from '../../debug.js' +import { GuardUser } from '../guard_user.js' +import { PROVIDER_REAL_USER } from '../symbols.js' +import type { + UserProviderContract, + LucidAuthenticatable, + LucidUserProviderOptions, +} from '../types.js' + +/** + * Lucid user represents a guard user, used by authentication guards + * to perform authentication. + */ +class LucidUser> extends GuardUser { + /** + * @inheritdoc + */ + getId(): string | number { + const id = this.realUser.$primaryKeyValue + + /** + * Ensure id exists + */ + if (!id) { + const model = this.realUser.constructor as LucidAuthenticatable + const modelName = model.name + const primaryKey = model.primaryKey + throw new RuntimeException( + `Cannot use "${modelName}" model for authentication. The value of column "${primaryKey}" is undefined or null` + ) + } + + return id + } + + /** + * @inheritdoc + */ + async verifyPassword(plainTextPassword: string): Promise { + return this.realUser.verifyPasswordForAuth(plainTextPassword) + } +} + +/** + * Lucid user provider is used to lookup user for authentication + * using a Lucid model. + */ +export abstract class LucidUserProvider + implements UserProviderContract> +{ + declare [PROVIDER_REAL_USER]: InstanceType + + /** + * Reference to the lazily imported model + */ + protected model?: UserModel + + constructor( + /** + * Model provider is used to lazily import the model + */ + protected modelProvider: () => Promise<{ default: UserModel }>, + + /** + * Lucid provider options + */ + protected options: LucidUserProviderOptions + ) { + debug('lucid_user_provider: options %O', options) + } + + /** + * Imports the model from the provider, returns and caches it + * for further operations. + */ + protected async getModel() { + if (this.model) { + return this.model + } + + const importedModel = await this.modelProvider() + this.model = importedModel.default + debug('lucid_user_provider: using model %O', this.model) + return this.model + } + + /** + * Returns an instance of the query builder + */ + protected getQueryBuilder(model: UserModel) { + return model.query({ + client: this.options.client, + connection: this.options.connection, + }) + } + + /** + * Returns an instance of the "LucidUser" that guards + * can use for authentication + */ + async createUserForGuard(user: InstanceType) { + const model = await this.getModel() + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + debug('lucid_user_provider: converting user object to guard user %O', user) + return new LucidUser(user) + } + + /** + * Finds a user by id using the configured model. + */ + async findById(value: string | number): Promise> | null> { + debug('lucid_user_provider: finding user by id %s', value) + + const model = await this.getModel() + const user = await model.find(value, { + client: this.options.client, + connection: this.options.connection, + }) + + if (!user) { + return null + } + + return new LucidUser(user) + } + + /** + * Finds a user using one of the pre-configured unique + * ids, via the configured model. + */ + async findByUid(value: string | number): Promise> | null> { + const query = this.getQueryBuilder(await this.getModel()) + this.options.uids.forEach((uid) => query.orWhere(uid, value)) + + debug( + 'lucid_user_provider: finding user by uids, uids: %O, value: %s', + this.options.uids, + value + ) + + const user = await query.limit(1).first() + if (!user) { + return null + } + + return new LucidUser(user) + } +} diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..dcffdd5 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { debuglog } from 'node:util' + +export default debuglog('adonisjs:auth') diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..ff5950b --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,19 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createError } from '@poppinss/utils' + +/** + * Invalid token provided + */ +export const E_INVALID_AUTH_TOKEN = createError( + 'Invalid or expired token value', + 'E_INVALID_AUTH_TOKEN', + 401 +) diff --git a/src/session/define_session_guard.ts b/src/session/define_session_guard.ts new file mode 100644 index 0000000..d1179c9 --- /dev/null +++ b/src/session/define_session_guard.ts @@ -0,0 +1,10 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export function defineSessionGuard() {} diff --git a/src/session/guard.ts b/src/session/guard.ts new file mode 100644 index 0000000..42d7095 --- /dev/null +++ b/src/session/guard.ts @@ -0,0 +1,140 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// + +import { Emitter } from '@adonisjs/core/events' +import { RuntimeException } from '@poppinss/utils' +import type { HttpContext } from '@adonisjs/core/http' + +import { PROVIDER_REAL_USER } from '../core/symbols.js' +import type { + SessionGuardEvents, + SessionGuardConfig, + SessionUserProviderContract, + RememberMeProviderContract, +} from './types.js' + +export class SessionGuard> { + /** + * A unique name for the guard. It is used for prefixing + * session data and remember me cookies + */ + #name: string + + /** + * Reference to the current HTTP context + */ + #ctx: HttpContext + + /** + * Configuration + */ + #config: SessionGuardConfig + + /** + * Provider to lookup user details + */ + #userProvider: UserProvider + + /** + * The remember me tokens provider to use to persist + * remember me tokens + */ + #rememberMeTokenProvider?: RememberMeProviderContract + + /** + * Emitter to emit events + */ + #emitter?: Emitter> + + /** + * The key used to store the logged-in user id inside + * session + */ + get sessionKeyName() { + return `auth_${this.#name}` + } + + /** + * The key used to store the remember me token cookie + */ + get rememberMeKeyName() { + return `remember_${this.#name}` + } + + constructor( + name: string, + config: SessionGuardConfig, + ctx: HttpContext, + userProvider: UserProvider + ) { + this.#name = name + this.#ctx = ctx + this.#config = config + this.#userProvider = userProvider + } + + /** + * Returns the session instance for the given request + */ + #getSession() { + if (!('session' in this.#ctx)) { + throw new RuntimeException( + 'Cannot login user. Make sure you have installed the "@adonisjs/session" package and configured its middleware' + ) + } + + return this.#ctx.session + } + + /** + * Register the remember me tokens provider to create + * remember me tokens during user login. + * + * Note: This method only registers the remember me tokens provider + * and does not enable them. You must pass "rememberMe = true" during + * the "login" method call. + */ + withRememberMeTokens(tokensProvider: RememberMeProviderContract): this { + this.#rememberMeTokenProvider = tokensProvider + return this + } + + /** + * Register an event emitter to listen for global events for + * authentication lifecycle. + */ + withEmitter(emitter: Emitter>): this { + this.#emitter = emitter + return this + } + + /** + * Login a user using the user object. + */ + async login(user: UserProvider[typeof PROVIDER_REAL_USER]) { + if (this.#emitter) { + this.#emitter.emit('session_auth:login_attempted', { user }) + } + + const providerUser = await this.#userProvider.createUserForGuard(user) + const session = this.#getSession() + + /** + * Create session and recycle the session id + */ + session.put(this.sessionKeyName, providerUser.getId()) + session.regenerate() + + if (this.#emitter) { + this.#emitter.emit('session_auth:login_succeeded', { user, sessionId: session.sessionId }) + } + } +} diff --git a/src/session/remember_me_token.ts b/src/session/remember_me_token.ts new file mode 100644 index 0000000..d8372c1 --- /dev/null +++ b/src/session/remember_me_token.ts @@ -0,0 +1,65 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Token } from '../core/token.js' +import { RememberMeTokenContract } from './types.js' + +/** + * Remember me token represents a remember me token created + * for a peristed login flow. + */ +export class RememberMeToken extends Token implements RememberMeTokenContract { + /** + * Static name for the token to uniquely identify a + * bucket of tokens + */ + readonly type: 'remember_me_token' = 'remember_me_token' + + /** + * Timestamp at which the token will expire + */ + declare expiresAt: Date + + constructor( + /** + * Reference to the user id for whom the token + * is generated + */ + public userId: string | number, + + /** + * Series is a random number stored inside the database as it is + */ + public series: string, + + /** + * Value is a random number only available at the time of issuing + * the token. Afterwards, the value is undefined. + */ + public value: string | undefined, + + /** + * Hash reference to the token hash + */ + public hash: string + ) { + super(series, value, hash) + } + + /** + * Create remember me token instance for a user + */ + static create(userId: string | number, expiry: string | number, size?: number): RememberMeToken { + const { series, value, hash } = this.seed(size) + const token = new RememberMeToken(userId, series, value, hash) + token.setExpiry(expiry) + + return token + } +} diff --git a/src/session/types.ts b/src/session/types.ts new file mode 100644 index 0000000..c6f0217 --- /dev/null +++ b/src/session/types.ts @@ -0,0 +1,119 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Exception } from '@poppinss/utils' + +import type { PROVIDER_REAL_USER } from '../core/symbols.js' +import type { + TokenContract, + UserProviderContract, + TokenProviderContract, + DatabaseTokenProviderOptions, +} from '../core/types.js' + +/** + * Representation of a remember me token + */ +export interface RememberMeTokenContract extends TokenContract { + /** + * Token type to uniquely identify a bucket of tokens + */ + readonly type: 'remember_me_token' + + /** + * Timestamp when the token will expire + */ + expiresAt: Date + + /** + * Reference to the user for whom the token is generated + */ + userId: string | number +} + +/** + * The SessionUserProvider is used to lookup a user for session based authentication. + */ +export interface SessionUserProviderContract extends UserProviderContract {} + +/** + * The RememberMeProviderContract is used to persist and lookup tokens for + * session based authentication with remember me option. + */ +export interface RememberMeProviderContract + extends TokenProviderContract {} + +/** + * Config accepted by the session guard + */ +export type SessionGuardConfig = { + /** + * The expiry for the remember me cookie. + * + * Defaults to "5 years" + */ + rememberMeTokenAge: string | number +} + +/** + * Events emitted by the session guard + */ +export type SessionGuardEvents> = { + /** + * The event is emitted when the user credentials + * have been verified successfully. + */ + 'session_auth:credentials_verified': { + uid: string + user: UserProvider[typeof PROVIDER_REAL_USER] + password: string + } + + /** + * The event is emitted when unable to login the + * user. + */ + 'session_auth:login_failed': { + error: Exception + user: UserProvider[typeof PROVIDER_REAL_USER] | null + } + + /** + * The event is emitted when login is attempted for + * a given user. + */ + 'session_auth:login_attempted': { + user: UserProvider[typeof PROVIDER_REAL_USER] + } + + /** + * The event is emitted when user has been logged in + * successfully + */ + 'session_auth:login_succeeded': { + user: UserProvider[typeof PROVIDER_REAL_USER] + sessionId: string + rememberMeToken?: RememberMeTokenContract + } + + /** + * The event is emitted when user has been logged out + * sucessfully + */ + 'session_auth:logged_out': { + user: UserProvider[typeof PROVIDER_REAL_USER] + sessionId: string + } +} + +/** + * Options accepted by the database implementation of the + * RememberMeProvider + */ +export type DatabaseRememberMeProviderOptions = DatabaseTokenProviderOptions diff --git a/tests/core/token.spec.ts b/tests/core/token.spec.ts new file mode 100644 index 0000000..abc02a7 --- /dev/null +++ b/tests/core/token.spec.ts @@ -0,0 +1,59 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { TestToken } from '../../factories/database_token_factory.js' + +test.group('Token', () => { + test('create a token', ({ assert }) => { + const token = new TestToken('1234', 'random-string', 'random-string-hash') + assert.equal(token.series, '1234') + assert.equal(token.value, 'random-string') + assert.equal(token.hash, 'random-string-hash') + assert.isDefined(token.createdAt) + assert.isUndefined(token.expiresAt) + assert.isUndefined(token.metaData) + assert.equal(token.type, 'test_token') + }) + + test('create a token with seeded values', ({ assert }) => { + const { series, value, hash } = TestToken.seed() + const token = new TestToken(series, value, hash) + assert.equal(token.series, series) + assert.equal(token.value, value) + assert.equal(token.hash, hash) + assert.isDefined(token.createdAt) + assert.isUndefined(token.expiresAt) + assert.isUndefined(token.metaData) + assert.equal(token.type, 'test_token') + }) + + test('verify value against the hash', ({ assert }) => { + const { series, value, hash } = TestToken.seed() + const token = new TestToken(series, value, hash) + + assert.isTrue(token.verify(TestToken.decode(value).value)) + }) + + test('set token metadata', ({ assert }) => { + const { series, value, hash } = TestToken.seed() + const token = new TestToken(series, value, hash) + token.setMetaData({ permissions: ['read-file', 'write-users'] }) + assert.deepEqual(token.metaData, { permissions: ['read-file', 'write-users'] }) + }) + + test('decode valid and invalid tokens', ({ assert }) => { + assert.throws(() => TestToken.decode('foo'), 'Invalid or expired token value') + assert.throws(() => TestToken.decode('foo.bar'), 'Invalid or expired token value') + + const { series, value } = TestToken.seed() + const decoded = TestToken.decode(value) + assert.equal(series, decoded.series) + }) +}) diff --git a/tests/core/token_providers/database.spec.ts b/tests/core/token_providers/database.spec.ts new file mode 100644 index 0000000..5ce3bf8 --- /dev/null +++ b/tests/core/token_providers/database.spec.ts @@ -0,0 +1,126 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { setTimeout } from 'node:timers/promises' +import { createDatabase, createTables } from '../../helpers.js' +import { DatabaseTokenProviderFactory, TestToken } from '../../../factories/main.js' + +test.group('Database token provider | createToken', () => { + test('persist a token to the database', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = TestToken.create(1, '10mins') + const databaseProvider = new DatabaseTokenProviderFactory().create(db) + + await databaseProvider.createToken(token) + const tokens = await db.query().from('remember_me_tokens') + + assert.lengthOf(tokens, 1) + assert.equal(tokens[0].user_id, 1) + assert.equal(tokens[0].series, token.series) + assert.exists(tokens[0].created_at) + assert.exists(tokens[0].updated_at) + assert.isAbove(tokens[0].expires_at, tokens[0].created_at) + + /** + * Creating a fresh token from the database entry + */ + const freshToken = new TestToken(tokens[0].series, undefined, tokens[0].token) + + /** + * Verifying the token public value matches the saved hash + */ + const { value } = TestToken.decode(token.value!) + assert.isTrue(freshToken.verify(value)) + }) + + test('find token by series', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = TestToken.create(1, '10mins') + const databaseProvider = new DatabaseTokenProviderFactory().create(db) + + await databaseProvider.createToken(token) + const freshToken = await databaseProvider.getTokenBySeries(token.series) + + /** + * Verifying the token public value matches the saved hash + */ + const { value } = TestToken.decode(token.value!) + assert.isTrue(freshToken!.verify(value)) + }) + + test("return null when token doesn't exists", async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const databaseProvider = new DatabaseTokenProviderFactory().create(db) + + assert.isNull(await databaseProvider.getTokenBySeries('foobar')) + }) + + test('return null when token is expired', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = TestToken.create(1, '2sec') + const databaseProvider = new DatabaseTokenProviderFactory().create(db) + + await databaseProvider.createToken(token) + await setTimeout(3000) + + assert.isNull(await databaseProvider.getTokenBySeries(token.series)) + }).timeout(4000) + + test('update token hash and expiry', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = TestToken.create(1, '2sec') + const databaseProvider = new DatabaseTokenProviderFactory().create(db) + + await databaseProvider.createToken(token) + + /** + * Wait for the token expire + */ + await setTimeout(3000) + assert.isNull(await databaseProvider.getTokenBySeries(token.series)) + + /** + * Update token expiry + */ + const dateInFuture = new Date() + dateInFuture.setSeconds(dateInFuture.getSeconds() * 60) + await databaseProvider.updateTokenBySeries(token.series, token.hash, dateInFuture) + + /** + * Ensure it has been set properly + */ + const freshToken = await databaseProvider.getTokenBySeries(token.series) + assert.isTrue(freshToken!.expiresAt! > new Date()) + }).timeout(4000) + + test('delete token by series', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = TestToken.create(1, '10mins') + const databaseProvider = new DatabaseTokenProviderFactory().create(db) + + await databaseProvider.createToken(token) + assert.isNotNull(await databaseProvider.getTokenBySeries(token.series)) + + await databaseProvider.deleteTokenBySeries(token.series) + assert.isNull(await databaseProvider.getTokenBySeries(token.series)) + }) +}) diff --git a/tests/core/user_providers/database/create_user_for_guard.spec.ts b/tests/core/user_providers/database/create_user_for_guard.spec.ts new file mode 100644 index 0000000..07b071a --- /dev/null +++ b/tests/core/user_providers/database/create_user_for_guard.spec.ts @@ -0,0 +1,43 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables } from '../../../helpers.js' +import { FactoryUser } from '../../../../factories/lucid_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' + +test.group('Database user provider | createUserForGuard', () => { + test('create a guard user from database row', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const { id } = await FactoryUser.createWithDefaults() + const user = await db.connection().from('users').where('id', id).first() + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const userByRow = await dbUserProvider.createUserForGuard(user) + + expectTypeOf(userByRow!.getOriginal()).toMatchTypeOf() + assert.equal(userByRow!.getId(), 1) + assert.deepEqual(userByRow!.getOriginal(), { + id: 1, + email: 'foo@bar.com', + username: 'foo', + password: 'secret', + }) + }) + + test('return error when user value is not an object', async () => { + const db = await createDatabase() + await createTables(db) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + await dbUserProvider.createUserForGuard(null as any) + }).throws('Invalid user object. It must be a database row object from the "users" table') +}) diff --git a/tests/core/user_providers/database/find_by_id.spec.ts b/tests/core/user_providers/database/find_by_id.spec.ts new file mode 100644 index 0000000..81e2da1 --- /dev/null +++ b/tests/core/user_providers/database/find_by_id.spec.ts @@ -0,0 +1,44 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables } from '../../../helpers.js' +import { FactoryUser } from '../../../../factories/lucid_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' + +test.group('Database user provider | findById', () => { + test('find a user using primary key', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults() + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const userById = await dbUserProvider.findById(1) + + expectTypeOf(userById!.getOriginal()).toMatchTypeOf() + assert.deepEqual(userById!.getOriginal(), { + id: 1, + email: 'foo@bar.com', + username: 'foo', + password: 'secret', + }) + assert.equal(userById!.getId(), 1) + }) + + test('return null when unable to find user by id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const userById = await dbUserProvider.findById(1) + + assert.isNull(userById) + }) +}) diff --git a/tests/core/user_providers/database/find_by_uid.spec.ts b/tests/core/user_providers/database/find_by_uid.spec.ts new file mode 100644 index 0000000..b5d5155 --- /dev/null +++ b/tests/core/user_providers/database/find_by_uid.spec.ts @@ -0,0 +1,54 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables } from '../../../helpers.js' +import { FactoryUser } from '../../../../factories/lucid_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' + +test.group('Database user provider | findByUId', () => { + test('find a user using primary key', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults() + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const userByUsername = await dbUserProvider.findByUid('foo') + const userByEmail = await dbUserProvider.findByUid('foo@bar.com') + + expectTypeOf(userByUsername!.getOriginal()).toMatchTypeOf() + assert.equal(userByUsername!.getId(), 1) + assert.deepEqual(userByUsername!.getOriginal(), { + id: 1, + email: 'foo@bar.com', + username: 'foo', + password: 'secret', + }) + + expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf() + assert.equal(userByEmail!.getId(), 1) + assert.deepEqual(userByEmail!.getOriginal(), { + id: 1, + email: 'foo@bar.com', + username: 'foo', + password: 'secret', + }) + }) + + test('return null when unable to find user by uid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + + assert.isNull(await dbUserProvider.findByUid('foo@bar.com')) + assert.isNull(await dbUserProvider.findByUid('foo')) + }) +}) diff --git a/tests/core/user_providers/database/guard_user.spec.ts b/tests/core/user_providers/database/guard_user.spec.ts new file mode 100644 index 0000000..96b6c77 --- /dev/null +++ b/tests/core/user_providers/database/guard_user.spec.ts @@ -0,0 +1,56 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables, getHasher } from '../../../helpers.js' +import { FactoryUser } from '../../../../factories/lucid_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' + +test.group('Database user provider | createUserForGuard', () => { + test('verify user password using guard user instance', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + password: await getHasher().make('secret'), + }) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const user = await dbUserProvider.findByUid('foo@bar.com') + + assert.isTrue(await user!.verifyPassword('secret')) + assert.isFalse(await user!.verifyPassword('foobar')) + }) + + test('throw error when value of password column is missing', async () => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + password: null, + }) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const user = await dbUserProvider.findByUid('foo@bar.com') + + await user!.verifyPassword('secret') + }).throws( + 'Cannot verify password during login. The value of column "password" is undefined or null' + ) + + test('throw error when value of id column is missing', async () => { + const db = await createDatabase() + await createTables(db) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const user = await dbUserProvider.createUserForGuard({ email: 'foo@bar.com', username: 'foo' }) + + user!.getId() + }).throws('Invalid user object. The value of column "id" is undefined or null') +}) diff --git a/tests/core/user_providers/lucid/create_user_for_guard.spec.ts b/tests/core/user_providers/lucid/create_user_for_guard.spec.ts new file mode 100644 index 0000000..29939e9 --- /dev/null +++ b/tests/core/user_providers/lucid/create_user_for_guard.spec.ts @@ -0,0 +1,41 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables } from '../../../helpers.js' +import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' + +test.group('Lucid user provider | createUserForGuard', () => { + test('create a guard user from a model instance', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const user = await FactoryUser.create({ + email: 'foo@bar.com', + username: 'foo', + password: 'secret', + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const userByInstance = await lucidUserProvider.createUserForGuard(user) + + expectTypeOf(userByInstance!.getOriginal()).toMatchTypeOf>() + assert.instanceOf(userByInstance!.getOriginal(), FactoryUser) + assert.isFalse(userByInstance!.getOriginal().$isNew) + assert.equal(userByInstance!.getId(), 1) + }) + + test('return error when user is not an instance of Model', async () => { + const db = await createDatabase() + await createTables(db) + + const lucidUserProvider = new LucidUserProviderFactory().create() + await lucidUserProvider.createUserForGuard({} as any) + }).throws('Invalid user object. It must be an instance of the "FactoryUser" model') +}) diff --git a/tests/core/user_providers/lucid/find_by_id.spec.ts b/tests/core/user_providers/lucid/find_by_id.spec.ts new file mode 100644 index 0000000..88acfbc --- /dev/null +++ b/tests/core/user_providers/lucid/find_by_id.spec.ts @@ -0,0 +1,39 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables } from '../../../helpers.js' +import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' + +test.group('Lucid user provider | findById', () => { + test('find a user using primary key', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.create({ email: 'foo@bar.com', username: 'foo', password: 'secret' }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const userById = await lucidUserProvider.findById(1) + + expectTypeOf(userById!.getOriginal()).toMatchTypeOf>() + assert.instanceOf(userById!.getOriginal(), FactoryUser) + assert.isFalse(userById!.getOriginal().$isNew) + assert.equal(userById!.getId(), 1) + }) + + test('return null when unable to find user by id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const userById = await lucidUserProvider.findById(1) + + assert.isNull(userById) + }) +}) diff --git a/tests/core/user_providers/lucid/find_by_uid.spec.ts b/tests/core/user_providers/lucid/find_by_uid.spec.ts new file mode 100644 index 0000000..4871c45 --- /dev/null +++ b/tests/core/user_providers/lucid/find_by_uid.spec.ts @@ -0,0 +1,45 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables } from '../../../helpers.js' +import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' + +test.group('Lucid user provider | findByUid', () => { + test('find a user for login using uids', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.create({ email: 'foo@bar.com', username: 'foo', password: 'secret' }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const userByEmail = await lucidUserProvider.findByUid('foo@bar.com') + const userByUsername = await lucidUserProvider.findByUid('foo@bar.com') + + expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf>() + assert.instanceOf(userByEmail!.getOriginal(), FactoryUser) + assert.isFalse(userByEmail!.getOriginal().$isNew) + assert.equal(userByEmail!.getId(), 1) + + expectTypeOf(userByUsername!.getOriginal()).toMatchTypeOf>() + assert.instanceOf(userByUsername!.getOriginal(), FactoryUser) + assert.isFalse(userByUsername!.getOriginal().$isNew) + assert.equal(userByUsername!.getId(), 1) + }) + + test('return null when unable to find user by uid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const lucidUserProvider = new LucidUserProviderFactory().create() + + assert.isNull(await lucidUserProvider.findByUid('foo@bar.com')) + assert.isNull(await lucidUserProvider.findByUid('foo')) + }) +}) diff --git a/tests/core/user_providers/lucid/guard_user.spec.ts b/tests/core/user_providers/lucid/guard_user.spec.ts new file mode 100644 index 0000000..fb2c22f --- /dev/null +++ b/tests/core/user_providers/lucid/guard_user.spec.ts @@ -0,0 +1,41 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createDatabase, createTables, getHasher } from '../../../helpers.js' +import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' + +test.group('Lucid user provider | LucidUser', () => { + test('verify user password using guard user instance', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + password: await getHasher().make('secret'), + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + + const user = await lucidUserProvider.findByUid('foo@bar.com') + assert.isTrue(await user!.verifyPassword('secret')) + assert.isFalse(await user!.verifyPassword('foobar')) + }) + + test('throw error when user primary key is missing', async () => { + const db = await createDatabase() + await createTables(db) + + const lucidUserProvider = new LucidUserProviderFactory().create() + + const user = await lucidUserProvider.createUserForGuard(new FactoryUser()) + user.getId() + }).throws( + 'Cannot use "FactoryUser" model for authentication. The value of column "id" is undefined or null' + ) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..372f7d0 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,94 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { join } from 'node:path' +import { Hash } from '@adonisjs/hash' +import { mkdir } from 'node:fs/promises' +import { getActiveTest } from '@japa/runner' +import { BaseModel } from '@adonisjs/lucid/orm' +import { Database } from '@adonisjs/lucid/database' +import { Scrypt } from '@adonisjs/hash/drivers/scrypt' +import { AppFactory } from '@adonisjs/core/factories/app' +import { LoggerFactory } from '@adonisjs/core/factories/logger' +import { EmitterFactory } from '@adonisjs/core/factories/events' + +/** + * Creates a fresh instance of AdonisJS hash module + * with scrypt driver + */ +export function getHasher() { + return new Hash(new Scrypt({})) +} + +/** + * Creates an instance of the database class for making queries + */ +export async function createDatabase() { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "createDatabase" outside of a Japa test') + } + + await mkdir(test.context.fs.basePath) + + const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) + const logger = new LoggerFactory().create() + const emitter = new EmitterFactory().create(app) + const db = new Database( + { + connection: 'primary', + connections: { + primary: { + client: 'sqlite3', + connection: { + filename: join(test.context.fs.basePath, 'db.sqlite3'), + }, + }, + }, + }, + logger, + emitter + ) + + test.cleanup(() => db.manager.closeAll()) + BaseModel.useAdapter(db.modelAdapter()) + return db +} + +/** + * Creates needed database tables + */ +export async function createTables(db: Database) { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "createTables" outside of a Japa test') + } + + test.cleanup(async () => { + await db.connection().schema.dropTable('users') + await db.connection().schema.dropTable('remember_me_tokens') + }) + + await db.connection().schema.createTable('users', (table) => { + table.increments() + table.string('username').unique().notNullable() + table.string('email').unique().notNullable() + table.string('password').nullable() + }) + + await db.connection().schema.createTable('remember_me_tokens', (table) => { + table.string('series', 60).notNullable() + table.integer('user_id').notNullable().unsigned() + table.string('type').notNullable() + table.string('token', 80).notNullable() + table.datetime('created_at').notNullable() + table.datetime('updated_at').notNullable() + table.datetime('expires_at').notNullable() + }) +} diff --git a/tests/modules/session/guard.spec.ts b/tests/modules/session/guard.spec.ts new file mode 100644 index 0000000..9d5b747 --- /dev/null +++ b/tests/modules/session/guard.spec.ts @@ -0,0 +1,34 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { createDatabase, createTables } from '../../helpers.js' +import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' + +test.group('Session guard | login', () => { + test('login a user using the user object', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.login(user) + }) + + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 2039043..ad0cc44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,4 +4,4 @@ "rootDir": "./", "outDir": "./build" } -} \ No newline at end of file +} From b4e0846b9b1e0b37b7ffca91adbb789a7c08ea34 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 12 Oct 2023 14:34:28 +0530 Subject: [PATCH 04/96] refactor: move symbols and errors out of core --- src/core/token.ts | 5 +++++ src/core/types.ts | 7 ++++++- src/core/user_providers/database.ts | 2 +- src/core/user_providers/lucid.ts | 2 +- src/errors.ts | 9 +++++++++ src/{core => }/symbols.ts | 5 +++++ tests/core/token_providers/database.spec.ts | 11 +++++------ 7 files changed, 32 insertions(+), 9 deletions(-) rename src/{core => }/symbols.ts (69%) diff --git a/src/core/token.ts b/src/core/token.ts index f8a8b35..6a875bc 100644 --- a/src/core/token.ts +++ b/src/core/token.ts @@ -42,6 +42,11 @@ export abstract class Token implements TokenContract { */ createdAt: Date = new Date() + /** + * Date/time when the token was updated + */ + updatedAt: Date = new Date() + constructor( /** * Series is a random number stored inside the database as it is diff --git a/src/core/types.ts b/src/core/types.ts index 75eca71..f467025 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -10,7 +10,7 @@ import type { QueryClientContract } from '@adonisjs/lucid/types/database' import type { GuardUser } from './guard_user.js' -import type { PROVIDER_REAL_USER } from './symbols.js' +import type { PROVIDER_REAL_USER } from '../symbols.js' import { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' /** @@ -53,6 +53,11 @@ export interface TokenContract { */ createdAt: Date + /** + * Timestamp when the token was updated + */ + updatedAt: Date + /** * Timestamp when the token will expire */ diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts index bbe0323..984d044 100644 --- a/src/core/user_providers/database.ts +++ b/src/core/user_providers/database.ts @@ -13,7 +13,7 @@ import type { Database } from '@adonisjs/lucid/database' import debug from '../../debug.js' import { GuardUser } from '../guard_user.js' -import { PROVIDER_REAL_USER } from '../symbols.js' +import { PROVIDER_REAL_USER } from '../../symbols.js' import type { DatabaseUserProviderOptions, UserProviderContract } from '../types.js' /** diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts index 30b1a38..0c1a0dc 100644 --- a/src/core/user_providers/lucid.ts +++ b/src/core/user_providers/lucid.ts @@ -11,7 +11,7 @@ import { RuntimeException } from '@poppinss/utils' import debug from '../../debug.js' import { GuardUser } from '../guard_user.js' -import { PROVIDER_REAL_USER } from '../symbols.js' +import { PROVIDER_REAL_USER } from '../../symbols.js' import type { UserProviderContract, LucidAuthenticatable, diff --git a/src/errors.ts b/src/errors.ts index ff5950b..2754dd1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -17,3 +17,12 @@ export const E_INVALID_AUTH_TOKEN = createError( 'E_INVALID_AUTH_TOKEN', 401 ) + +/** + * The user session is invalid + */ +export const E_INVALID_AUTH_SESSION = createError( + 'Invalid or expired authentication session', + 'E_INVALID_AUTH_SESSION', + 401 +) diff --git a/src/core/symbols.ts b/src/symbols.ts similarity index 69% rename from src/core/symbols.ts rename to src/symbols.ts index 29f7c26..5ee6d8a 100644 --- a/src/core/symbols.ts +++ b/src/symbols.ts @@ -12,3 +12,8 @@ * user provider */ export const PROVIDER_REAL_USER = Symbol.for('PROVIDER_REAL_USER') + +/** + * A symbol to identify the type for the events emitted by a guard + */ +export const GUARD_KNOWN_EVENTS = Symbol.for('GUARD_KNOWN_EVENTS') diff --git a/tests/core/token_providers/database.spec.ts b/tests/core/token_providers/database.spec.ts index 5ce3bf8..37733ef 100644 --- a/tests/core/token_providers/database.spec.ts +++ b/tests/core/token_providers/database.spec.ts @@ -8,8 +8,7 @@ */ import { test } from '@japa/runner' -import { setTimeout } from 'node:timers/promises' -import { createDatabase, createTables } from '../../helpers.js' +import { createDatabase, createTables, timeTravel } from '../../helpers.js' import { DatabaseTokenProviderFactory, TestToken } from '../../../factories/main.js' test.group('Database token provider | createToken', () => { @@ -72,14 +71,14 @@ test.group('Database token provider | createToken', () => { const db = await createDatabase() await createTables(db) - const token = TestToken.create(1, '2sec') + const token = TestToken.create(1, '2 sec') const databaseProvider = new DatabaseTokenProviderFactory().create(db) await databaseProvider.createToken(token) - await setTimeout(3000) + timeTravel(3) assert.isNull(await databaseProvider.getTokenBySeries(token.series)) - }).timeout(4000) + }) test('update token hash and expiry', async ({ assert }) => { const db = await createDatabase() @@ -93,7 +92,7 @@ test.group('Database token provider | createToken', () => { /** * Wait for the token expire */ - await setTimeout(3000) + timeTravel(3) assert.isNull(await databaseProvider.getTokenBySeries(token.series)) /** From c47f5e824a568fd6470b579ef2e6172040fbc041 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 13 Oct 2023 06:02:59 +0530 Subject: [PATCH 05/96] feat: initial setup to test package in a app --- bin/test.ts | 2 +- factories/lucid_user_provider.ts | 2 +- index.ts | 12 + package.json | 17 +- src/auth_manager.ts | 38 ++ src/authenticator.ts | 6 +- src/core/README.md | 2 + src/core/token.ts | 2 +- src/define_config.ts | 39 ++ src/session/guard.ts | 350 +++++++++++++++++- src/session/main.ts | 110 ++++++ .../{remember_me_token.ts => token.ts} | 0 src/session/token_providers/main.ts | 66 ++++ src/session/types.ts | 38 +- src/session/user_providers/main.ts | 29 ++ .../extended.ts} | 2 - src/types/main.ts | 54 +++ tests/guards/session/authenticate.spec.ts | 304 +++++++++++++++ tests/guards/session/get_user.spec.ts | 53 +++ tests/guards/session/login.spec.ts | 136 +++++++ tests/helpers.ts | 107 +++++- tests/modules/session/guard.spec.ts | 34 -- 22 files changed, 1338 insertions(+), 65 deletions(-) create mode 100644 index.ts create mode 100644 src/auth_manager.ts create mode 100644 src/define_config.ts create mode 100644 src/session/main.ts rename src/session/{remember_me_token.ts => token.ts} (100%) create mode 100644 src/session/token_providers/main.ts create mode 100644 src/session/user_providers/main.ts rename src/{session/define_session_guard.ts => types/extended.ts} (80%) create mode 100644 src/types/main.ts create mode 100644 tests/guards/session/authenticate.spec.ts create mode 100644 tests/guards/session/get_user.spec.ts create mode 100644 tests/guards/session/login.spec.ts delete mode 100644 tests/modules/session/guard.spec.ts diff --git a/bin/test.ts b/bin/test.ts index 542479b..e6a3787 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -9,7 +9,7 @@ configure({ suites: [ { name: 'session', - files: ['tests/modules/session/**/*.spec.ts'], + files: ['tests/guards/session/**/*.spec.ts'], }, { name: 'core', diff --git a/factories/lucid_user_provider.ts b/factories/lucid_user_provider.ts index f89b26d..d6e3d20 100644 --- a/factories/lucid_user_provider.ts +++ b/factories/lucid_user_provider.ts @@ -12,7 +12,7 @@ import { BaseModel, column } from '@adonisjs/lucid/orm' import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' import { LucidUserProvider } from '../src/core/user_providers/lucid.js' import { LucidAuthenticatable, LucidUserProviderOptions } from '../src/core/types.js' -import { PROVIDER_REAL_USER } from '../src/core/symbols.js' +import { PROVIDER_REAL_USER } from '../src/symbols.js' export class FactoryUser extends BaseModel { static table = 'users' diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..cda0e5c --- /dev/null +++ b/index.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export * as errors from './src/errors.js' +export * as symbols from './src/symbols.js' +export { Authenticator } from './src/authenticator.js' diff --git a/package.json b/package.json index 7c4d155..f7917b8 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,16 @@ }, "exports": { ".": "./build/index.js", - "./services/main": "./build/services/main.js", - "./auth_provider": "./build/providers/auth_provider.js", - "./factories": "./build/factories/main.js", - "./types": "./build/src/types/main.js" + "./types": "./build/src/types/main.js", + "./core/token": "./build/src/core/token.js", + "./core/guard_user": "./build/src/core/guard_user.js", + "./core/user_providers/*": "./build/src/core/user_providers/*.js", + "./core/token_providers/*": "./build/src/core/token_providers/*.js", + "./session": "./build/src/session/main.js", + "./session/user_providers": "./build/src/session/user_providers/main.js", + "./session/token_providers": "./build/src/session/token_providers/main.js", + "./types/session": "./build/src/session/types.js", + "./types/core": "./build/src/core/types.js" }, "scripts": { "pretest": "npm run lint", @@ -75,6 +81,7 @@ "@swc/core": "1.3.82", "@types/luxon": "^3.3.2", "@types/node": "^20.8.3", + "@types/set-cookie-parser": "^2.4.4", "c8": "^8.0.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", @@ -85,7 +92,9 @@ "luxon": "^3.4.3", "np": "^8.0.4", "prettier": "^3.0.3", + "set-cookie-parser": "^2.6.0", "sqlite3": "^5.1.6", + "timekeeper": "^2.3.1", "ts-node": "^10.9.1", "typescript": "^5.2.2" }, diff --git a/src/auth_manager.ts b/src/auth_manager.ts new file mode 100644 index 0000000..ee6bbff --- /dev/null +++ b/src/auth_manager.ts @@ -0,0 +1,38 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' + +import { Authenticator } from './authenticator.js' +import type { AuthenticatorGuardFactory } from './types/main.js' + +/** + * Auth manager exposes the API to register and manage authentication + * guards from the config + */ +export class AuthManager> { + /** + * Registered guards + */ + #config: { + default?: keyof KnownGuards + guards: KnownGuards + } + + constructor(config: { default?: keyof KnownGuards; guards: KnownGuards }) { + this.#config = config + } + + /** + * Create an authenticator for a given HTTP request + */ + createAuthenticator(ctx: HttpContext) { + return new Authenticator(ctx, this.#config) + } +} diff --git a/src/authenticator.ts b/src/authenticator.ts index 45016cc..1a920af 100644 --- a/src/authenticator.ts +++ b/src/authenticator.ts @@ -7,14 +7,16 @@ * file that was distributed with this source code. */ -import { HttpContext } from '@adonisjs/core/http' +import type { HttpContext } from '@adonisjs/core/http' + import debug from './debug.js' +import type { AuthenticatorGuardFactory } from './types/main.js' /** * Authenticator is an HTTP request specific implementation for using * guards to login users and authenticate requests. */ -export class Authenticator unknown>> { +export class Authenticator> { /** * Reference to HTTP context */ diff --git a/src/core/README.md b/src/core/README.md index fee2881..caebede 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -2,3 +2,5 @@ The core part of the codebase provides base implementations that can be used by the first and third party guards and providers. These base implementations must not be used inside the user-land code and the main purpose is to provide ready to use abstractions for guards and providers. + +If you decide to contribut additional implementations, make sure to mark them as `abstract` to avoid direct usage. diff --git a/src/core/token.ts b/src/core/token.ts index 6a875bc..69acae7 100644 --- a/src/core/token.ts +++ b/src/core/token.ts @@ -8,8 +8,8 @@ */ import { createHash } from 'node:crypto' -import { base64, safeEqual } from '@adonisjs/core/helpers' import string from '@adonisjs/core/helpers/string' +import { base64, safeEqual } from '@adonisjs/core/helpers' import * as errors from '../errors.js' import type { TokenContract } from './types.js' diff --git a/src/define_config.ts b/src/define_config.ts new file mode 100644 index 0000000..ff94705 --- /dev/null +++ b/src/define_config.ts @@ -0,0 +1,39 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { AuthenticatorGuardFactory, ConfigProvider } from './types/main.js' + +/** + * Define configuration for the auth package. The function returns + * a config provider that is invoked inside the auth service + * provider + */ +export function defineConfig< + KnownGuards extends Record, +>(config: { + default: keyof KnownGuards + guards: { [K in keyof KnownGuards]: ConfigProvider } +}): ConfigProvider<{ + default: keyof KnownGuards + guards: { [K in keyof KnownGuards]: KnownGuards[K] } +}> { + return async function (_, app) { + const guardsList = Object.keys(config.guards) as (keyof KnownGuards)[] + const guards = {} as { [K in keyof KnownGuards]: KnownGuards[K] } + + for (let guard of guardsList) { + guards[guard] = await config.guards[guard](guard as string, app) + } + + return { + default: config.default, + guards, + } + } +} diff --git a/src/session/guard.ts b/src/session/guard.ts index 42d7095..0706585 100644 --- a/src/session/guard.ts +++ b/src/session/guard.ts @@ -10,18 +10,31 @@ /// import { Emitter } from '@adonisjs/core/events' -import { RuntimeException } from '@poppinss/utils' import type { HttpContext } from '@adonisjs/core/http' +import { Exception, RuntimeException } from '@poppinss/utils' -import { PROVIDER_REAL_USER } from '../core/symbols.js' +import debug from '../debug.js' +import * as errors from '../errors.js' +import { RememberMeToken } from './token.js' +import type { GuardContract } from '../types/main.js' +import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../symbols.js' import type { SessionGuardEvents, SessionGuardConfig, - SessionUserProviderContract, + RememberMeTokenContract, RememberMeProviderContract, + SessionUserProviderContract, } from './types.js' -export class SessionGuard> { +/** + * Session guard uses sessions and cookies to login and authenticate + * users. + */ +export class SessionGuard> + implements GuardContract +{ + declare [GUARD_KNOWN_EVENTS]: SessionGuardEvents + /** * A unique name for the guard. It is used for prefixing * session data and remember me cookies @@ -52,7 +65,46 @@ export class SessionGuard> + #emitter?: Emitter> + + /** + * Whether or not the authentication has been attempted + * during the current request + */ + authenticationAttempted = false + + /** + * Find if the user has been logged out during + * the current request + */ + isLoggedOut = false + + /** + * A boolean to know if the current request has + * been authenticated + */ + isAuthenticated = false + + /** + * A boolean to know if the current request is authenticated + * using the "rememember_me" token. + */ + viaRemember = false + + /** + * Reference to an instance of the authenticated or logged-in + * user. The value only exists after calling one of the + * following methods. + * + * - login + * - loginViaId + * - attempt + * - authenticate + * + * You can use the "getUserOrFail" method to throw an exception if + * the request is not authenticated. + */ + user?: UserProvider[typeof PROVIDER_REAL_USER] /** * The key used to store the logged-in user id inside @@ -82,7 +134,22 @@ export class SessionGuard>): this { + withEmitter(emitter: Emitter): this { this.#emitter = emitter return this } + /** + * Returns an instance of the authenticated user. Or throws + * an exception if the request is not authenticated. + */ + getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { + if (!this.user) { + throw new errors.E_INVALID_AUTH_SESSION() + } + + return this.user + } + /** * Login a user using the user object. */ - async login(user: UserProvider[typeof PROVIDER_REAL_USER]) { + async login( + user: UserProvider[typeof PROVIDER_REAL_USER], + remember: boolean = false + ): Promise { if (this.#emitter) { this.#emitter.emit('session_auth:login_attempted', { user }) } @@ -130,11 +226,245 @@ export class SessionGuard { + try { + await this.authenticate() + return true + } catch (error) { + if ( + error instanceof errors.E_INVALID_AUTH_SESSION || + error instanceof errors.E_INVALID_AUTH_TOKEN + ) { + return false + } + + throw error } } } diff --git a/src/session/main.ts b/src/session/main.ts new file mode 100644 index 0000000..cdaed59 --- /dev/null +++ b/src/session/main.ts @@ -0,0 +1,110 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' + +import type { + LucidAuthenticatable, + LucidUserProviderOptions, + DatabaseUserProviderOptions, +} from '../core/types.js' + +import type { + SessionGuardConfig, + RememberMeProviderContract, + SessionUserProviderContract, + DatabaseRememberMeProviderOptions, +} from './types.js' + +import { SessionGuard } from './guard.js' +import { ConfigProvider } from '../types/main.js' +import type { DatabaseRememberTokenProvider } from './token_providers/main.js' + +export { RememberMeToken } from './token.js' +export { SessionGuard } + +/** + * Helper function to configure the session guard for + * authentication. + * + * This method returns a config builder, which internally + * returns a factory function to construct a guard + * during HTTP requests. + */ +export function sessionGuard>( + config: SessionGuardConfig & { + provider: ConfigProvider + tokens?: ConfigProvider + } +): ConfigProvider<(ctx: HttpContext) => SessionGuard> { + return async (key, app) => { + const emitter = await app.container.make('emitter') + const provider = await config.provider('provider', app) + const tokensProvider = config.tokens ? await config.tokens('tokens', app) : undefined + + /** + * Factory function needed by Authenticator to switch + * between guards and perform authentication + */ + return (ctx) => { + const guard = new SessionGuard(key, config, ctx, provider) + if (tokensProvider) { + guard.withRememberMeTokens(tokensProvider) + } + + return guard.withEmitter(emitter) + } + } +} + +/** + * Helpers to configure user and tokens provider + * for the session guard + */ +export const sessionProviders: { + users: { + lucid: ( + config: LucidUserProviderOptions & { + model: () => Promise<{ default: UserModel }> + } + ) => ConfigProvider>> + db: >( + config: DatabaseUserProviderOptions + ) => ConfigProvider> + } + tokens: { + db: (config: DatabaseRememberMeProviderOptions) => ConfigProvider + } +} = { + users: { + lucid: (config) => { + return async () => { + const { LucidSessionUserProvider } = await import('./user_providers/main.js') + return new LucidSessionUserProvider(config.model, config) + } + }, + db: (config) => { + return async (_, app) => { + const db = await app.container.make('lucid.db') + const hash = await app.container.make('hash') + const { DatabaseSessionUserProvider } = await import('./user_providers/main.js') + return new DatabaseSessionUserProvider(db, hash.use(), config) + } + }, + }, + tokens: { + db: (config) => { + return async (_, app) => { + const db = await app.container.make('lucid.db') + const { DatabaseRememberTokenProvider } = await import('./token_providers/main.js') + return new DatabaseRememberTokenProvider(db, config) + } + }, + }, +} diff --git a/src/session/remember_me_token.ts b/src/session/token.ts similarity index 100% rename from src/session/remember_me_token.ts rename to src/session/token.ts diff --git a/src/session/token_providers/main.ts b/src/session/token_providers/main.ts new file mode 100644 index 0000000..beafb43 --- /dev/null +++ b/src/session/token_providers/main.ts @@ -0,0 +1,66 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { RememberMeToken } from '../token.js' +import type { RememberMeProviderContract } from '../types.js' +import { DatabaseTokenProvider } from '../../core/token_providers/database.js' + +/** + * Remember me token provider to persist tokens inside the database + * using db query builder. + */ +export class DatabaseRememberTokenProvider + extends DatabaseTokenProvider + implements RememberMeProviderContract +{ + /** + * Prepares a token from the database result + */ + protected prepareToken(dbRow: { + series: string + user_id: string | number + type: string + token: string + created_at: Date + updated_at: Date + expires_at: Date | null + }): RememberMeToken { + const token = new RememberMeToken(dbRow.user_id, dbRow.series, undefined, dbRow.token) + if (dbRow.expires_at) { + token.expiresAt = dbRow.expires_at + } + token.createdAt = dbRow.created_at + token.updatedAt = dbRow.updated_at + + return token + } + + /** + * Converts the remember me token into a database row + */ + protected parseToken(token: RememberMeToken): { + series: string + user_id: string | number + type: string + token: string + created_at: Date + updated_at: Date + expires_at: Date | null + } { + return { + series: token.series, + user_id: token.userId, + type: token.type, + token: token.hash, + created_at: token.createdAt, + updated_at: token.updatedAt, + expires_at: token.expiresAt, + } + } +} diff --git a/src/session/types.ts b/src/session/types.ts index c6f0217..6f316fb 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -8,8 +8,6 @@ */ import { Exception } from '@poppinss/utils' - -import type { PROVIDER_REAL_USER } from '../core/symbols.js' import type { TokenContract, UserProviderContract, @@ -64,14 +62,14 @@ export type SessionGuardConfig = { /** * Events emitted by the session guard */ -export type SessionGuardEvents> = { +export type SessionGuardEvents = { /** * The event is emitted when the user credentials * have been verified successfully. */ 'session_auth:credentials_verified': { uid: string - user: UserProvider[typeof PROVIDER_REAL_USER] + user: User password: string } @@ -81,7 +79,7 @@ export type SessionGuardEvents + extends LucidUserProvider + implements SessionUserProviderContract> {} + +/** + * Using database query builder to find users for + * session auth + */ +export class DatabaseSessionUserProvider> + extends DatabaseUserProvider + implements SessionUserProviderContract {} diff --git a/src/session/define_session_guard.ts b/src/types/extended.ts similarity index 80% rename from src/session/define_session_guard.ts rename to src/types/extended.ts index d1179c9..a4048c0 100644 --- a/src/session/define_session_guard.ts +++ b/src/types/extended.ts @@ -6,5 +6,3 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - -export function defineSessionGuard() {} diff --git a/src/types/main.ts b/src/types/main.ts new file mode 100644 index 0000000..0bc3f98 --- /dev/null +++ b/src/types/main.ts @@ -0,0 +1,54 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Emitter } from '@adonisjs/core/events' +import type { HttpContext } from '@adonisjs/core/http' +import { ApplicationService } from '@adonisjs/core/types' + +import type { GUARD_KNOWN_EVENTS } from '../symbols.js' + +/** + * A set of properties a guard must implement. + */ +export interface GuardContract { + /** + * Reference to the user type + */ + user?: User + + /** + * Aymbol for infer the events emitted by a specific + * guard + */ + [GUARD_KNOWN_EVENTS]: unknown + + /** + * Accept an instance of the emitter to emit events + */ + withEmitter(emitter: Emitter): this +} + +/** + * Config providers are async function that needs app instance + * and returns configuration + */ +export type ConfigProvider = (key: string, app: ApplicationService) => Promise + +/** + * The authenticator guard factory method is called by the + * Authenticator class to create an instance of a specific + * guard during an HTTP request + */ +export type AuthenticatorGuardFactory = (ctx: HttpContext) => GuardContract + +/** + * Authenticators are inferred inside the user application + * from the config file + */ +export interface Authenticators {} diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts new file mode 100644 index 0000000..94b1f82 --- /dev/null +++ b/tests/guards/session/authenticate.spec.ts @@ -0,0 +1,304 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { RememberMeToken } from '../../../src/session/token.js' +import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { DatabaseRememberTokenProvider } from '../../../src/session/token_providers/main.js' +import { + timeTravel, + parseCookies, + createTables, + defineCookies, + createDatabase, + pEvent, + createEmitter, +} from '../../helpers.js' + +test.group('Session guard | authenticate', () => { + test('authenticate existing session for auth', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [authSucceeded] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_succeeded'), + sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + await sessionGuard.authenticate() + expectTypeOf(sessionGuard.authenticate).returns.toMatchTypeOf>() + }), + ]) + + assert.equal(authSucceeded!.sessionId, ctx.session.sessionId) + assert.equal(authSucceeded!.user.id, user.id) + assert.isUndefined(authSucceeded!.rememberMeToken) + assert.equal(sessionGuard.getUserOrFail().id, user.id) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isTrue(sessionGuard.isAuthenticated) + assert.isTrue(sessionGuard.authenticationAttempted) + assert.isFalse(sessionGuard.viaRemember) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + }) + + test('throw error when session does not have user id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [authFailed, authenticateCall] = await Promise.allSettled([ + pEvent(emitter, 'session_auth:authentication_failed'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authenticateCall.status, 'rejected') + assert.equal( + ('reason' in authenticateCall && authenticateCall.reason).message, + 'Invalid or expired authentication session' + ) + }) + + test('throw error when session has id but user has been deleted', async () => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await user.delete() + + await sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + await sessionGuard.authenticate() + }) + }).throws('Invalid or expired authentication session') + + test('login user via remember me token when session does not have user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const sessionGuard = new SessionGuardFactory() + .create(ctx) + .withRememberMeTokens(tokensProvider) + .withEmitter(emitter) + + const token = RememberMeToken.create(user.id, '1 year') + await tokensProvider.createToken(token) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!, + type: 'encrypted', + }, + ]) + + const [authSucceeded] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_succeeded'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }), + ]) + + assert.equal(authSucceeded!.sessionId, ctx.session.sessionId) + assert.equal(authSucceeded!.user.id, user.id) + assert.exists(authSucceeded!.rememberMeToken) + assert.equal(sessionGuard.getUserOrFail().id, user.id) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isTrue(sessionGuard.isAuthenticated) + assert.isTrue(sessionGuard.authenticationAttempted) + assert.isTrue(sessionGuard.viaRemember) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + + /** + * Since the token was generated within 1 minute of using + * it. We do not refresh it inside the db + */ + const freshToken = await tokensProvider.getTokenBySeries(token.series) + assert.equal(freshToken!.hash, token.hash) + + const parsedCookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) + assert.equal(parsedCookies.remember_web.value, token.value) + }) + + test('refresh remember me token when using it after 1 min of last update', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const token = RememberMeToken.create(user.id, '1 year') + await tokensProvider.createToken(token) + + /** + * Travel 1 minute in future + */ + timeTravel(60) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!, + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }) + + assert.equal(sessionGuard.getUserOrFail().id, user.id) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isTrue(sessionGuard.isAuthenticated) + assert.isTrue(sessionGuard.authenticationAttempted) + assert.isTrue(sessionGuard.viaRemember) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + + /** + * Since the token was generated within 1 minute of using + * it. We do not refresh it inside the db + */ + const freshToken = await tokensProvider.getTokenBySeries(token.series) + assert.notEqual(freshToken!.hash, token.hash) + assert.equal(freshToken!.series, token.series) + + const parsedCookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) + assert.notEqual(parsedCookies.remember_web.value, token.value) + }) + + test('throw error when remember me token has been expired', async () => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const token = RememberMeToken.create(user.id, '1 minute') + await tokensProvider.createToken(token) + + /** + * Travel 2 minute in future + */ + timeTravel(120) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!, + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }) + }).throws('Invalid or expired authentication session') + + test('throw error when remember me token does not exist', async () => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const token = RememberMeToken.create(user.id, '1 year') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!, + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }) + }).throws('Invalid or expired authentication session') + + test('throw error when user for remember me token has been deleted', async () => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const token = RememberMeToken.create(user.id, '1 year') + await tokensProvider.createToken(token) + + await user.delete() + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!, + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }) + }).throws('Invalid or expired authentication session') + + test('multiple calls to authenticate should result in noop', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + await sessionGuard.authenticate() + await user.delete() + const authUser = await sessionGuard.authenticate() + assert.equal(authUser.id, user.id) + }) + }) +}) diff --git a/tests/guards/session/get_user.spec.ts b/tests/guards/session/get_user.spec.ts new file mode 100644 index 0000000..aced9a4 --- /dev/null +++ b/tests/guards/session/get_user.spec.ts @@ -0,0 +1,53 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { createTables, createDatabase } from '../../helpers.js' +import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' + +test.group('Session guard | getUser', () => { + test('get user when authentication succeeded', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + await sessionGuard.authenticate() + }) + + assert.equal(sessionGuard.getUserOrFail().id, user.id) + expectTypeOf(sessionGuard.getUserOrFail()).toMatchTypeOf() + }) + + test('throw error when authentication failed and getUser is called', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await assert.rejects(async () => { + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }) + }) + + assert.throws(() => sessionGuard.getUserOrFail(), 'Invalid or expired authentication session') + }) +}) diff --git a/tests/guards/session/login.spec.ts b/tests/guards/session/login.spec.ts new file mode 100644 index 0000000..3b40d8d --- /dev/null +++ b/tests/guards/session/login.spec.ts @@ -0,0 +1,136 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { RememberMeToken } from '../../../src/session/token.js' +import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { DatabaseRememberTokenProvider } from '../../../src/session/token_providers/main.js' +import { createDatabase, createEmitter, createTables, pEvent, parseCookies } from '../../helpers.js' + +test.group('Session guard | login', () => { + test('login a user using the user object', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.login(user) + }) + + assert.strictEqual(sessionGuard.user, user) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isFalse(sessionGuard.isAuthenticated) + assert.isFalse(sessionGuard.authenticationAttempted) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + }) + + test('emit events around user login', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [loginAttempted, loginSucceeded] = await Promise.all([ + pEvent(emitter, 'session_auth:login_attempted'), + pEvent(emitter, 'session_auth:login_succeeded'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.login(user) + }), + ]) + + assert.strictEqual(loginAttempted!.user, user) + assert.strictEqual(loginSucceeded!.user, user) + assert.equal(loginSucceeded!.sessionId, ctx.session.sessionId) + assert.isUndefined(loginSucceeded!.rememberMeToken) + }) + + test('create remember me cookie', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.login(user, true) + }) + + assert.strictEqual(sessionGuard.user, user) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isFalse(sessionGuard.isAuthenticated) + assert.isFalse(sessionGuard.authenticationAttempted) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + + /** + * Parsing response cookies + */ + const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) + assert.property(cookies, 'remember_web') + assert.equal(cookies.remember_web.maxAge, 157788000) + assert.equal(cookies.remember_web.httpOnly, true) + + /** + * Ensure the remember me cookie can be decoded by + * the server + */ + const decodedToken = RememberMeToken.decode(cookies.remember_web.value) + assert.properties(decodedToken, ['series', 'value']) + + /** + * Verifying the cookie exists in the database + */ + const persistedToken = await tokensProvider.getTokenBySeries(decodedToken.series) + assert.exists(persistedToken) + assert.isTrue(persistedToken!.verify(decodedToken.value)) + }) + + test('throw error when trying to create remember_me token with tokens provider', async () => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.login(user, true) + }) + }).throws( + 'Cannot use "rememberMe" feature. Please configure the tokens provider inside config/auth file' + ) + + test('throw error when trying to use session guard without session middleware', async () => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + + await sessionGuard.login(user) + }).throws( + 'Cannot login user. Make sure you have installed the "@adonisjs/session" package and configured its middleware' + ) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 372f7d0..9cf36be 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -8,15 +8,22 @@ */ import { join } from 'node:path' +import timekeeper from 'timekeeper' import { Hash } from '@adonisjs/hash' import { mkdir } from 'node:fs/promises' import { getActiveTest } from '@japa/runner' +import { Emitter } from '@adonisjs/core/events' import { BaseModel } from '@adonisjs/lucid/orm' +import { CookieClient } from '@adonisjs/core/http' import { Database } from '@adonisjs/lucid/database' import { Scrypt } from '@adonisjs/hash/drivers/scrypt' import { AppFactory } from '@adonisjs/core/factories/app' +import setCookieParser, { CookieMap } from 'set-cookie-parser' import { LoggerFactory } from '@adonisjs/core/factories/logger' -import { EmitterFactory } from '@adonisjs/core/factories/events' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' + +import { SessionGuardEvents } from '../src/session/types.js' +import { FactoryUser } from '../factories/lucid_user_provider.js' /** * Creates a fresh instance of AdonisJS hash module @@ -39,7 +46,7 @@ export async function createDatabase() { const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) const logger = new LoggerFactory().create() - const emitter = new EmitterFactory().create(app) + const emitter = new Emitter(app) const db = new Database( { connection: 'primary', @@ -92,3 +99,99 @@ export async function createTables(db: Database) { table.datetime('expires_at').notNullable() }) } + +/** + * Creates an emitter instance for testing with typed + * events + */ +export function createEmitter() { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "createEmitter" outside of a Japa test') + } + + const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) + return new Emitter>(app) +} + +/** + * Promisify an event + */ +export function pEvent, K extends keyof T>( + emitter: Emitter, + event: K, + timeout: number = 500 +) { + return new Promise((resolve) => { + function handler(data: T[K]) { + emitter.off(event, handler) + resolve(data) + } + + setTimeout(() => { + emitter.off(event, handler) + resolve(null) + }, timeout) + emitter.on(event, handler) + }) +} + +/** + * Parses set-cookie header + */ +export function parseCookies(setCookiesHeader: string | string[]) { + const cookies = setCookieParser(setCookiesHeader, { map: true }) + const client = new CookieClient(new EncryptionFactory().create()) + + return Object.keys(cookies).reduce((result, key) => { + result[key] = { + ...cookies[key], + value: client.parse(cookies[key].name, cookies[key].value), + } + return result + }, {} as CookieMap) +} + +/** + * Define cookies for the request cookie header + */ +export function defineCookies( + cookies: { + key: string + value: string + type: 'plain' | 'encrypted' | 'signed' + }[] +) { + const client = new CookieClient(new EncryptionFactory().create()) + + return cookies + .reduce((result, cookie) => { + const value = + cookie.type === 'plain' + ? client.encode(cookie.key, cookie.value) + : cookie.type === 'encrypted' + ? client.encrypt(cookie.key, cookie.value) + : client.sign(cookie.key, cookie.value) + + result.push(`${cookie.key}=${value}`) + return result + }, [] as string[]) + .join(';') +} + +export function timeTravel(secondsToTravel: number) { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "timeTravel" outside of a Japa test') + } + + timekeeper.reset() + + const date = new Date() + date.setSeconds(date.getSeconds() + secondsToTravel) + timekeeper.travel(date) + + test.cleanup(() => { + timekeeper.reset() + }) +} diff --git a/tests/modules/session/guard.spec.ts b/tests/modules/session/guard.spec.ts deleted file mode 100644 index 9d5b747..0000000 --- a/tests/modules/session/guard.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { createDatabase, createTables } from '../../helpers.js' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' - -test.group('Session guard | login', () => { - test('login a user using the user object', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.login(user) - }) - - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - }) -}) From 929e6f48bd1e973ddf25e2ce74dc52998ea97bed Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 13 Oct 2023 06:05:11 +0530 Subject: [PATCH 06/96] chore: add peer dependencies --- package.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/package.json b/package.json index f7917b8..6f3925e 100644 --- a/package.json +++ b/package.json @@ -129,5 +129,18 @@ }, "dependencies": { "@poppinss/utils": "^6.5.0-7" + }, + "peerDependencies": { + "@adonisjs/core": "^6.1.5-26", + "@adonisjs/lucid": "^19.0.0-2", + "@adonisjs/session": "^7.0.0-11" + }, + "peerDependenciesMeta": { + "@adonisjs/lucid": { + "optional": true + }, + "@adonisjs/session": { + "optional": true + } } } From 317601f8b627420200f44e579b74bf3091495fa2 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 23 Oct 2023 09:51:22 +0530 Subject: [PATCH 07/96] refactor: remove client option from db providers --- src/core/token_providers/database.ts | 8 ++------ src/core/types.ts | 20 +------------------- src/core/user_providers/database.ts | 4 +--- 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/core/token_providers/database.ts b/src/core/token_providers/database.ts index 1255da5..1311457 100644 --- a/src/core/token_providers/database.ts +++ b/src/core/token_providers/database.ts @@ -59,9 +59,7 @@ export abstract class DatabaseTokenProvider implements TokenProviderContr * Returns an instance of the query builder */ protected getQueryBuilder() { - return this.options.client - ? this.options.client.query() - : this.db.connection(this.options.connection).query() + return this.db.connection(this.options.connection).query() } /** @@ -69,9 +67,7 @@ export abstract class DatabaseTokenProvider implements TokenProviderContr * queries */ protected getInsertQueryBuilder() { - return this.options.client - ? this.options.client.insertQuery() - : this.db.connection(this.options.connection).insertQuery() + return this.db.connection(this.options.connection).insertQuery() } /** diff --git a/src/core/types.ts b/src/core/types.ts index f467025..8bce70d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -11,7 +11,7 @@ import type { QueryClientContract } from '@adonisjs/lucid/types/database' import type { GuardUser } from './guard_user.js' import type { PROVIDER_REAL_USER } from '../symbols.js' -import { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' +import type { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' /** * A token represents an opaque token issued to a client @@ -171,15 +171,6 @@ export type DatabaseUserProviderOptions> = */ connection?: string - /** - * Optionally define the query client instance to use for making - * database queries. - * - * When both "connection" and "client" are defined, the client will - * be given the preference. - */ - client?: QueryClientContract - /** * Database table to query to find the user */ @@ -212,15 +203,6 @@ export type DatabaseTokenProviderOptions = { */ connection?: string - /** - * Optionally define the query client instance to use for making - * database queries. - * - * When both "connection" and "client" are defined, the client will - * be given the preference. - */ - client?: QueryClientContract - /** * Database table to query to find the user */ diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts index 984d044..02013ff 100644 --- a/src/core/user_providers/database.ts +++ b/src/core/user_providers/database.ts @@ -100,9 +100,7 @@ export abstract class DatabaseUserProvider> * Returns an instance of the query builder */ protected getQueryBuilder() { - return this.options.client - ? this.options.client.query() - : this.db.connection(this.options.connection).query() + return this.db.connection(this.options.connection).query() } /** From 564987d3cb4c23827b3a4611c59272014ecdf0cf Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 23 Oct 2023 14:02:19 +0530 Subject: [PATCH 08/96] feat: finish base implementations and session guard --- bin/test.ts | 4 + factories/database_user_provider.ts | 4 +- factories/lucid_user_provider.ts | 36 +++--- factories/session_guard_factory.ts | 7 +- index.ts | 8 +- package.json | 38 +++--- providers/auth_provider.ts | 40 ++++++ services/auth.ts | 22 ++++ src/{ => auth}/auth_manager.ts | 8 +- src/{ => auth}/authenticator.ts | 22 ++-- src/{ => auth}/debug.ts | 0 src/auth/define_config.ts | 94 ++++++++++++++ src/{ => auth}/errors.ts | 0 src/{ => auth}/symbols.ts | 0 src/{types/main.ts => auth/types.ts} | 35 ++++-- src/auth/user_providers/main.ts | 28 +++++ src/core/README.md | 2 +- src/core/token.ts | 2 +- src/core/token_providers/database.ts | 2 +- src/core/types.ts | 7 +- src/core/user_providers/database.ts | 6 +- src/core/user_providers/lucid.ts | 13 +- src/define_config.ts | 39 ------ src/guards/session/define_config.ts | 79 ++++++++++++ src/{ => guards}/session/guard.ts | 21 ++-- .../extended.ts => guards/session/main.ts} | 4 + src/{ => guards}/session/token.ts | 5 +- .../session/token_providers/main.ts | 2 +- src/{ => guards}/session/types.ts | 34 ++---- src/session/main.ts | 110 ----------------- src/session/user_providers/main.ts | 29 ----- tests/auth/authenticator.spec.ts | 52 ++++++++ tests/auth/define_config.spec.ts | 115 ++++++++++++++++++ tests/guards/session/authenticate.spec.ts | 27 +++- tests/guards/session/define_config.spec.ts | 84 +++++++++++++ tests/guards/session/login.spec.ts | 4 +- tests/helpers.ts | 2 +- 37 files changed, 678 insertions(+), 307 deletions(-) create mode 100644 providers/auth_provider.ts create mode 100644 services/auth.ts rename src/{ => auth}/auth_manager.ts (71%) rename src/{ => auth}/authenticator.ts (64%) rename src/{ => auth}/debug.ts (100%) create mode 100644 src/auth/define_config.ts rename src/{ => auth}/errors.ts (100%) rename src/{ => auth}/symbols.ts (100%) rename src/{types/main.ts => auth/types.ts} (52%) create mode 100644 src/auth/user_providers/main.ts delete mode 100644 src/define_config.ts create mode 100644 src/guards/session/define_config.ts rename src/{ => guards}/session/guard.ts (95%) rename src/{types/extended.ts => guards/session/main.ts} (53%) rename src/{ => guards}/session/token.ts (88%) rename src/{ => guards}/session/token_providers/main.ts (95%) rename src/{ => guards}/session/types.ts (77%) delete mode 100644 src/session/main.ts delete mode 100644 src/session/user_providers/main.ts create mode 100644 tests/auth/authenticator.spec.ts create mode 100644 tests/auth/define_config.spec.ts create mode 100644 tests/guards/session/define_config.spec.ts diff --git a/bin/test.ts b/bin/test.ts index e6a3787..45bbeb5 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -11,6 +11,10 @@ configure({ name: 'session', files: ['tests/guards/session/**/*.spec.ts'], }, + { + name: 'auth', + files: ['tests/auth/**/*.spec.ts'], + }, { name: 'core', files: ['tests/core/**/*.spec.ts'], diff --git a/factories/database_user_provider.ts b/factories/database_user_provider.ts index e18f582..3b8c96d 100644 --- a/factories/database_user_provider.ts +++ b/factories/database_user_provider.ts @@ -10,11 +10,11 @@ import { Hash } from '@adonisjs/core/hash' import type { Database } from '@adonisjs/lucid/database' import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' -import { DatabaseUserProvider } from '../src/core/user_providers/database.js' +import { BaseDatabaseUserProvider } from '../src/core/user_providers/database.js' export class TestDatabaseUserProvider< RealUser extends Record, -> extends DatabaseUserProvider {} +> extends BaseDatabaseUserProvider {} /** * Creates an instance of the DatabaseUserProvider with sane diff --git a/factories/lucid_user_provider.ts b/factories/lucid_user_provider.ts index d6e3d20..3055473 100644 --- a/factories/lucid_user_provider.ts +++ b/factories/lucid_user_provider.ts @@ -10,9 +10,10 @@ import { Hash } from '@adonisjs/core/hash' import { BaseModel, column } from '@adonisjs/lucid/orm' import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' -import { LucidUserProvider } from '../src/core/user_providers/lucid.js' -import { LucidAuthenticatable, LucidUserProviderOptions } from '../src/core/types.js' -import { PROVIDER_REAL_USER } from '../src/symbols.js' + +import { PROVIDER_REAL_USER } from '../src/auth/symbols.js' +import { BaseLucidUserProvider } from '../src/core/user_providers/lucid.js' +import type { LucidAuthenticatable, LucidUserProviderOptions } from '../src/core/types.js' export class FactoryUser extends BaseModel { static table = 'users' @@ -49,7 +50,7 @@ export class FactoryUser extends BaseModel { export class TestLucidUserProvider< UserModel extends LucidAuthenticatable, -> extends LucidUserProvider { +> extends BaseLucidUserProvider { declare [PROVIDER_REAL_USER]: InstanceType } @@ -58,23 +59,20 @@ export class TestLucidUserProvider< * defaults for testing */ export class LucidUserProviderFactory { - createForModel( - model: Model, - options: LucidUserProviderOptions - ) { - return new TestLucidUserProvider( - async () => { - return { - default: model, - } - }, - { - ...options, - } - ) + createForModel(options: LucidUserProviderOptions) { + return new TestLucidUserProvider({ + ...options, + }) } create() { - return this.createForModel(FactoryUser, { uids: ['email', 'username'] }) + return this.createForModel({ + model: async () => { + return { + default: FactoryUser, + } + }, + uids: ['email', 'username'], + }) } } diff --git a/factories/session_guard_factory.ts b/factories/session_guard_factory.ts index de4d632..c445017 100644 --- a/factories/session_guard_factory.ts +++ b/factories/session_guard_factory.ts @@ -9,8 +9,11 @@ import type { HttpContext } from '@adonisjs/core/http' -import { SessionGuard } from '../src/session/guard.js' -import type { SessionGuardConfig, SessionUserProviderContract } from '../src/session/types.js' +import { SessionGuard } from '../src/guards/session/guard.js' +import type { + SessionGuardConfig, + SessionUserProviderContract, +} from '../src/guards/session/types.js' import { FactoryUser, TestLucidUserProvider, diff --git a/index.ts b/index.ts index cda0e5c..fd0f64e 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,8 @@ * file that was distributed with this source code. */ -export * as errors from './src/errors.js' -export * as symbols from './src/symbols.js' -export { Authenticator } from './src/authenticator.js' +export * as errors from './src/auth/errors.js' +export * as symbols from './src/auth/symbols.js' +export { Authenticator } from './src/auth/authenticator.js' +export { AuthManager } from './src/auth/auth_manager.js' +export { defineConfig, providers } from './src/auth/define_config.js' diff --git a/package.json b/package.json index 6f3925e..efa73eb 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,15 @@ "exports": { ".": "./build/index.js", "./types": "./build/src/types/main.js", + "./core/token": "./build/src/core/token.js", "./core/guard_user": "./build/src/core/guard_user.js", "./core/user_providers/*": "./build/src/core/user_providers/*.js", "./core/token_providers/*": "./build/src/core/token_providers/*.js", - "./session": "./build/src/session/main.js", - "./session/user_providers": "./build/src/session/user_providers/main.js", - "./session/token_providers": "./build/src/session/token_providers/main.js", - "./types/session": "./build/src/session/types.js", - "./types/core": "./build/src/core/types.js" + "./types/core": "./build/src/core/types.js", + + "./session": "./build/src/guards/session/main.js", + "./types/session": "./build/src/guards/session/types.js" }, "scripts": { "pretest": "npm run lint", @@ -65,28 +65,28 @@ "url": "https://github.com/adonisjs/auth/issues" }, "devDependencies": { - "@adonisjs/core": "^6.1.5-26", + "@adonisjs/core": "^6.1.5-30", "@adonisjs/eslint-config": "^1.1.8", - "@adonisjs/lucid": "^19.0.0-2", + "@adonisjs/lucid": "^19.0.0-3", "@adonisjs/prettier-config": "^1.1.8", - "@adonisjs/session": "^7.0.0-11", + "@adonisjs/session": "^7.0.0-13", "@adonisjs/tsconfig": "^1.1.8", - "@commitlint/cli": "^17.7.2", - "@commitlint/config-conventional": "^17.7.0", - "@japa/assert": "^2.0.0-2", - "@japa/expect-type": "^2.0.0-1", - "@japa/file-system": "^2.0.0-2", - "@japa/runner": "^3.0.1", + "@commitlint/cli": "^18.0.0", + "@commitlint/config-conventional": "^18.0.0", + "@japa/assert": "^2.0.0", + "@japa/expect-type": "^2.0.0", + "@japa/file-system": "^2.0.0", + "@japa/runner": "^3.0.4", "@japa/snapshot": "^2.0.0", "@swc/core": "1.3.82", - "@types/luxon": "^3.3.2", - "@types/node": "^20.8.3", - "@types/set-cookie-parser": "^2.4.4", + "@types/luxon": "^3.3.3", + "@types/node": "^20.8.7", + "@types/set-cookie-parser": "^2.4.5", "c8": "^8.0.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", - "eslint": "^8.25.0", + "eslint": "^8.52.0", "github-label-sync": "^2.3.1", "husky": "^8.0.3", "luxon": "^3.4.3", @@ -128,7 +128,7 @@ ] }, "dependencies": { - "@poppinss/utils": "^6.5.0-7" + "@poppinss/utils": "^6.5.0" }, "peerDependencies": { "@adonisjs/core": "^6.1.5-26", diff --git a/providers/auth_provider.ts b/providers/auth_provider.ts new file mode 100644 index 0000000..875416d --- /dev/null +++ b/providers/auth_provider.ts @@ -0,0 +1,40 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { configProvider } from '@adonisjs/core' +import { RuntimeException } from '@poppinss/utils' +import type { ApplicationService } from '@adonisjs/core/types' + +import { AuthManager } from '../src/auth/auth_manager.js' +import type { AuthService } from '../src/auth/types.js' + +declare module '@adonisjs/core/types' { + export interface ContainerBindings { + 'auth.manager': AuthService + } +} + +export default class AuthProvider { + constructor(protected app: ApplicationService) {} + + register() { + this.app.container.singleton('auth.manager', async () => { + const authConfigProvider = this.app.config.get('auth') + const config = await configProvider.resolve(this.app, authConfigProvider) + + if (!config) { + throw new RuntimeException( + 'Invalid config exported from "config/auth.ts" file. Make sure to use the defineConfig method' + ) + } + + return new AuthManager(config) + }) + } +} diff --git a/services/auth.ts b/services/auth.ts new file mode 100644 index 0000000..0bb8e11 --- /dev/null +++ b/services/auth.ts @@ -0,0 +1,22 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import app from '@adonisjs/core/services/app' +import { AuthService } from '../src/auth/types.js' + +let auth: AuthService + +/** + * Returns a singleton instance of the Auth manager class + */ +await app.booted(async () => { + auth = await app.container.make('auth.manager') +}) + +export { auth as default } diff --git a/src/auth_manager.ts b/src/auth/auth_manager.ts similarity index 71% rename from src/auth_manager.ts rename to src/auth/auth_manager.ts index ee6bbff..7ad9dfa 100644 --- a/src/auth_manager.ts +++ b/src/auth/auth_manager.ts @@ -9,23 +9,23 @@ import type { HttpContext } from '@adonisjs/core/http' +import type { GuardFactory } from './types.js' import { Authenticator } from './authenticator.js' -import type { AuthenticatorGuardFactory } from './types/main.js' /** * Auth manager exposes the API to register and manage authentication * guards from the config */ -export class AuthManager> { +export class AuthManager> { /** * Registered guards */ #config: { - default?: keyof KnownGuards + default: keyof KnownGuards guards: KnownGuards } - constructor(config: { default?: keyof KnownGuards; guards: KnownGuards }) { + constructor(config: { default: keyof KnownGuards; guards: KnownGuards }) { this.#config = config } diff --git a/src/authenticator.ts b/src/auth/authenticator.ts similarity index 64% rename from src/authenticator.ts rename to src/auth/authenticator.ts index 1a920af..1886abf 100644 --- a/src/authenticator.ts +++ b/src/auth/authenticator.ts @@ -10,13 +10,13 @@ import type { HttpContext } from '@adonisjs/core/http' import debug from './debug.js' -import type { AuthenticatorGuardFactory } from './types/main.js' +import type { GuardFactory } from './types.js' /** * Authenticator is an HTTP request specific implementation for using * guards to login users and authenticate requests. */ -export class Authenticator> { +export class Authenticator> { /** * Reference to HTTP context */ @@ -26,7 +26,7 @@ export class Authenticator> = {} - constructor(ctx: HttpContext, config: { default?: keyof KnownGuards; guards: KnownGuards }) { + constructor(ctx: HttpContext, config: { default: keyof KnownGuards; guards: KnownGuards }) { this.#ctx = ctx this.#config = config debug('creating authenticator. config %O', this.#config) @@ -45,24 +45,26 @@ export class Authenticator(guard: Guard): ReturnType { + use(guard?: Guard): ReturnType { + const guardToUse = guard || this.#config.default + /** * Use cached copy if exists */ - const cachedGuard = this.#guardsCache[guard] + const cachedGuard = this.#guardsCache[guardToUse] if (cachedGuard) { - debug('using guard from cache. name: "%s"', guard) + debug('using guard from cache. name: "%s"', guardToUse) return cachedGuard as ReturnType } - const guardFactory = this.#config.guards[guard] + const guardFactory = this.#config.guards[guardToUse] /** * Construct guard and cache it */ - debug('creating guard. name: "%s"', guard) + debug('creating guard. name: "%s"', guardToUse) const guardInstance = guardFactory(this.#ctx) - this.#guardsCache[guard] = guardInstance + this.#guardsCache[guardToUse] = guardInstance return guardInstance as ReturnType } diff --git a/src/debug.ts b/src/auth/debug.ts similarity index 100% rename from src/debug.ts rename to src/auth/debug.ts diff --git a/src/auth/define_config.ts b/src/auth/define_config.ts new file mode 100644 index 0000000..62623a7 --- /dev/null +++ b/src/auth/define_config.ts @@ -0,0 +1,94 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// + +import { configProvider } from '@adonisjs/core' +import type { ConfigProvider } from '@adonisjs/core/types' + +import type { GuardConfigProvider, GuardFactory } from './types.js' +import type { LucidUserProvider, DatabaseUserProvider } from './user_providers/main.js' +import type { + LucidAuthenticatable, + LucidUserProviderOptions, + DatabaseUserProviderOptions, +} from '../core/types.js' + +/** + * Config resolved by the "defineConfig" method + */ +export type ResolvedAuthConfig< + KnownGuards extends Record>, +> = { + default: keyof KnownGuards + guards: { + [K in keyof KnownGuards]: KnownGuards[K] extends GuardConfigProvider + ? A + : KnownGuards[K] + } +} + +/** + * Define configuration for the auth package. The function returns + * a config provider that is invoked inside the auth service + * provider + */ +export function defineConfig< + KnownGuards extends Record>, +>(config: { + default: keyof KnownGuards + guards: KnownGuards +}): ConfigProvider> { + return configProvider.create(async (app) => { + const guardsList = Object.keys(config.guards) + const guards = {} as Record + + for (let guardName of guardsList) { + const guard = config.guards[guardName] + if (typeof guard === 'function') { + guards[guardName] = guard + } else { + guards[guardName] = await guard.resolver(guardName, app) + } + } + + return { + default: config.default, + guards: guards, + } as ResolvedAuthConfig + }) +} + +/** + * Providers helper to configure user providers for + * finding users for authentication + */ +export const providers: { + db: >( + config: DatabaseUserProviderOptions + ) => ConfigProvider> + lucid: ( + config: LucidUserProviderOptions + ) => ConfigProvider> +} = { + db(config) { + return configProvider.create(async (app) => { + const db = await app.container.make('lucid.db') + const hasher = await app.container.make('hash') + const { DatabaseUserProvider } = await import('./user_providers/main.js') + return new DatabaseUserProvider(db, hasher.use(), config) + }) + }, + lucid(config) { + return configProvider.create(async () => { + const { LucidUserProvider } = await import('./user_providers/main.js') + return new LucidUserProvider(config) + }) + }, +} diff --git a/src/errors.ts b/src/auth/errors.ts similarity index 100% rename from src/errors.ts rename to src/auth/errors.ts diff --git a/src/symbols.ts b/src/auth/symbols.ts similarity index 100% rename from src/symbols.ts rename to src/auth/symbols.ts diff --git a/src/types/main.ts b/src/auth/types.ts similarity index 52% rename from src/types/main.ts rename to src/auth/types.ts index 0bc3f98..23aa4f2 100644 --- a/src/types/main.ts +++ b/src/auth/types.ts @@ -9,9 +9,10 @@ import type { Emitter } from '@adonisjs/core/events' import type { HttpContext } from '@adonisjs/core/http' -import { ApplicationService } from '@adonisjs/core/types' +import type { ApplicationService, ConfigProvider } from '@adonisjs/core/types' -import type { GUARD_KNOWN_EVENTS } from '../symbols.js' +import type { AuthManager } from './auth_manager.js' +import type { GUARD_KNOWN_EVENTS } from './symbols.js' /** * A set of properties a guard must implement. @@ -34,21 +35,37 @@ export interface GuardContract { withEmitter(emitter: Emitter): this } -/** - * Config providers are async function that needs app instance - * and returns configuration - */ -export type ConfigProvider = (key: string, app: ApplicationService) => Promise - /** * The authenticator guard factory method is called by the * Authenticator class to create an instance of a specific * guard during an HTTP request */ -export type AuthenticatorGuardFactory = (ctx: HttpContext) => GuardContract +export type GuardFactory = (ctx: HttpContext) => GuardContract /** * Authenticators are inferred inside the user application * from the config file */ export interface Authenticators {} + +/** + * Infer authenticators from the auth config + */ +export type InferAuthenticators> = Awaited< + ReturnType +> + +/** + * Auth service is a singleton instance of the AuthManager + * configured using the config stored within the user + * app. + */ +export interface AuthService + extends AuthManager {} + +/** + * Config provider for exporting guard + */ +export type GuardConfigProvider = { + resolver: (name: string, app: ApplicationService) => Promise +} diff --git a/src/auth/user_providers/main.ts b/src/auth/user_providers/main.ts new file mode 100644 index 0000000..0653ff3 --- /dev/null +++ b/src/auth/user_providers/main.ts @@ -0,0 +1,28 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseLucidUserProvider } from '../../core/user_providers/lucid.js' +import { BaseDatabaseUserProvider } from '../../core/user_providers/database.js' +import type { LucidAuthenticatable, UserProviderContract } from '../../core/types.js' + +/** + * Using lucid models to find users for session + * auth + */ +export class LucidUserProvider + extends BaseLucidUserProvider + implements UserProviderContract> {} + +/** + * Using database query builder to find users for + * session auth + */ +export class DatabaseUserProvider> + extends BaseDatabaseUserProvider + implements UserProviderContract {} diff --git a/src/core/README.md b/src/core/README.md index caebede..e830ad4 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -3,4 +3,4 @@ The core part of the codebase provides base implementations that can be used by These base implementations must not be used inside the user-land code and the main purpose is to provide ready to use abstractions for guards and providers. -If you decide to contribut additional implementations, make sure to mark them as `abstract` to avoid direct usage. +If you decide to contribute additional implementations, make sure to mark them as `abstract` to avoid direct usage. diff --git a/src/core/token.ts b/src/core/token.ts index 69acae7..4b03f02 100644 --- a/src/core/token.ts +++ b/src/core/token.ts @@ -11,7 +11,7 @@ import { createHash } from 'node:crypto' import string from '@adonisjs/core/helpers/string' import { base64, safeEqual } from '@adonisjs/core/helpers' -import * as errors from '../errors.js' +import * as errors from '../auth/errors.js' import type { TokenContract } from './types.js' /** diff --git a/src/core/token_providers/database.ts b/src/core/token_providers/database.ts index 1311457..51fbbaa 100644 --- a/src/core/token_providers/database.ts +++ b/src/core/token_providers/database.ts @@ -9,7 +9,7 @@ import type { Database } from '@adonisjs/lucid/database' -import debug from '../../debug.js' +import debug from '../../auth/debug.js' import type { DatabaseTokenProviderOptions, TokenProviderContract } from '../types.js' /** diff --git a/src/core/types.ts b/src/core/types.ts index 8bce70d..8982bf2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -10,7 +10,7 @@ import type { QueryClientContract } from '@adonisjs/lucid/types/database' import type { GuardUser } from './guard_user.js' -import type { PROVIDER_REAL_USER } from '../symbols.js' +import type { PROVIDER_REAL_USER } from '../auth/symbols.js' import type { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' /** @@ -154,6 +154,11 @@ export type LucidUserProviderOptions = { */ client?: QueryClientContract + /** + * Model to use for authentication + */ + model: () => Promise<{ default: Model }> + /** * An array of uids to use when finding a user for login. Make * sure all fields can be used to uniquely lookup a user. diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts index 02013ff..ea99031 100644 --- a/src/core/user_providers/database.ts +++ b/src/core/user_providers/database.ts @@ -11,9 +11,9 @@ import type { Hash } from '@adonisjs/core/hash' import { RuntimeException } from '@poppinss/utils' import type { Database } from '@adonisjs/lucid/database' -import debug from '../../debug.js' +import debug from '../../auth/debug.js' import { GuardUser } from '../guard_user.js' -import { PROVIDER_REAL_USER } from '../../symbols.js' +import { PROVIDER_REAL_USER } from '../../auth/symbols.js' import type { DatabaseUserProviderOptions, UserProviderContract } from '../types.js' /** @@ -71,7 +71,7 @@ class DatabaseUser> extends GuardUser> +export abstract class BaseDatabaseUserProvider> implements UserProviderContract { declare [PROVIDER_REAL_USER]: RealUser diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts index 0c1a0dc..6afc40f 100644 --- a/src/core/user_providers/lucid.ts +++ b/src/core/user_providers/lucid.ts @@ -9,9 +9,9 @@ import { RuntimeException } from '@poppinss/utils' -import debug from '../../debug.js' +import debug from '../../auth/debug.js' import { GuardUser } from '../guard_user.js' -import { PROVIDER_REAL_USER } from '../../symbols.js' +import { PROVIDER_REAL_USER } from '../../auth/symbols.js' import type { UserProviderContract, LucidAuthenticatable, @@ -56,7 +56,7 @@ class LucidUser> extends Gua * Lucid user provider is used to lookup user for authentication * using a Lucid model. */ -export abstract class LucidUserProvider +export abstract class BaseLucidUserProvider implements UserProviderContract> { declare [PROVIDER_REAL_USER]: InstanceType @@ -67,11 +67,6 @@ export abstract class LucidUserProvider protected model?: UserModel constructor( - /** - * Model provider is used to lazily import the model - */ - protected modelProvider: () => Promise<{ default: UserModel }>, - /** * Lucid provider options */ @@ -89,7 +84,7 @@ export abstract class LucidUserProvider return this.model } - const importedModel = await this.modelProvider() + const importedModel = await this.options.model() this.model = importedModel.default debug('lucid_user_provider: using model %O', this.model) return this.model diff --git a/src/define_config.ts b/src/define_config.ts deleted file mode 100644 index ff94705..0000000 --- a/src/define_config.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { AuthenticatorGuardFactory, ConfigProvider } from './types/main.js' - -/** - * Define configuration for the auth package. The function returns - * a config provider that is invoked inside the auth service - * provider - */ -export function defineConfig< - KnownGuards extends Record, ->(config: { - default: keyof KnownGuards - guards: { [K in keyof KnownGuards]: ConfigProvider } -}): ConfigProvider<{ - default: keyof KnownGuards - guards: { [K in keyof KnownGuards]: KnownGuards[K] } -}> { - return async function (_, app) { - const guardsList = Object.keys(config.guards) as (keyof KnownGuards)[] - const guards = {} as { [K in keyof KnownGuards]: KnownGuards[K] } - - for (let guard of guardsList) { - guards[guard] = await config.guards[guard](guard as string, app) - } - - return { - default: config.default, - guards, - } - } -} diff --git a/src/guards/session/define_config.ts b/src/guards/session/define_config.ts new file mode 100644 index 0000000..9de5f32 --- /dev/null +++ b/src/guards/session/define_config.ts @@ -0,0 +1,79 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { configProvider } from '@adonisjs/core' +import { RuntimeException } from '@poppinss/utils' +import type { HttpContext } from '@adonisjs/core/http' +import type { ConfigProvider } from '@adonisjs/core/types' + +import { SessionGuard } from './guard.js' +import type { GuardConfigProvider } from '../../auth/types.js' +import type { + SessionGuardConfig, + RememberMeProviderContract, + SessionUserProviderContract, + DatabaseRememberMeProviderOptions, +} from './types.js' + +/** + * Helper function to configure the session guard for + * authentication. + * + * This method returns a config builder, which internally + * returns a factory function to construct a guard + * during HTTP requests. + */ +export function sessionGuard>( + config: SessionGuardConfig & { + provider: ConfigProvider + tokens?: ConfigProvider + } +): GuardConfigProvider<(ctx: HttpContext) => SessionGuard> { + return { + async resolver(guardName, app) { + const provider = await configProvider.resolve(app, config.provider) + if (!provider) { + throw new RuntimeException(`Invalid user provider defined on "${guardName}" guard`) + } + + const emitter = await app.container.make('emitter') + const tokensProvider = config.tokens + ? await configProvider.resolve(app, config.tokens) + : undefined + + /** + * Factory function needed by Authenticator to switch + * between guards and perform authentication + */ + return (ctx) => { + const guard = new SessionGuard(guardName, config, ctx, provider) + if (tokensProvider) { + guard.withRememberMeTokens(tokensProvider) + } + + return guard.withEmitter(emitter) + } + }, + } +} + +/** + * Tokens provider helper to store remember me tokens + */ +export const tokensProvider: { + db: (config: DatabaseRememberMeProviderOptions) => ConfigProvider +} = { + db(config) { + return configProvider.create(async (app) => { + const db = await app.container.make('lucid.db') + const { DatabaseRememberTokenProvider } = await import('./token_providers/main.js') + return new DatabaseRememberTokenProvider(db, config) + }) + }, +} diff --git a/src/session/guard.ts b/src/guards/session/guard.ts similarity index 95% rename from src/session/guard.ts rename to src/guards/session/guard.ts index 0706585..c8f0488 100644 --- a/src/session/guard.ts +++ b/src/guards/session/guard.ts @@ -13,15 +13,14 @@ import { Emitter } from '@adonisjs/core/events' import type { HttpContext } from '@adonisjs/core/http' import { Exception, RuntimeException } from '@poppinss/utils' -import debug from '../debug.js' -import * as errors from '../errors.js' +import debug from '../../auth/debug.js' import { RememberMeToken } from './token.js' -import type { GuardContract } from '../types/main.js' -import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../symbols.js' +import * as errors from '../../auth/errors.js' +import type { GuardContract } from '../../auth/types.js' +import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../auth/symbols.js' import type { SessionGuardEvents, SessionGuardConfig, - RememberMeTokenContract, RememberMeProviderContract, SessionUserProviderContract, } from './types.js' @@ -235,14 +234,17 @@ export class SessionGuard extends UserProviderContr * The RememberMeProviderContract is used to persist and lookup tokens for * session based authentication with remember me option. */ -export interface RememberMeProviderContract - extends TokenProviderContract {} +export interface RememberMeProviderContract extends TokenProviderContract {} /** * Config accepted by the session guard @@ -56,7 +36,7 @@ export type SessionGuardConfig = { * * Defaults to "5 years" */ - rememberMeTokenAge: string | number + rememberMeTokenAge?: string | number } /** @@ -97,7 +77,7 @@ export type SessionGuardEvents = { 'session_auth:login_succeeded': { user: User sessionId: string - rememberMeToken?: RememberMeTokenContract + rememberMeToken?: RememberMeToken } /** @@ -113,7 +93,7 @@ export type SessionGuardEvents = { 'session_auth:authentication_succeeded': { user: User sessionId: string - rememberMeToken?: RememberMeTokenContract + rememberMeToken?: RememberMeToken } /** diff --git a/src/session/main.ts b/src/session/main.ts deleted file mode 100644 index cdaed59..0000000 --- a/src/session/main.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { HttpContext } from '@adonisjs/core/http' - -import type { - LucidAuthenticatable, - LucidUserProviderOptions, - DatabaseUserProviderOptions, -} from '../core/types.js' - -import type { - SessionGuardConfig, - RememberMeProviderContract, - SessionUserProviderContract, - DatabaseRememberMeProviderOptions, -} from './types.js' - -import { SessionGuard } from './guard.js' -import { ConfigProvider } from '../types/main.js' -import type { DatabaseRememberTokenProvider } from './token_providers/main.js' - -export { RememberMeToken } from './token.js' -export { SessionGuard } - -/** - * Helper function to configure the session guard for - * authentication. - * - * This method returns a config builder, which internally - * returns a factory function to construct a guard - * during HTTP requests. - */ -export function sessionGuard>( - config: SessionGuardConfig & { - provider: ConfigProvider - tokens?: ConfigProvider - } -): ConfigProvider<(ctx: HttpContext) => SessionGuard> { - return async (key, app) => { - const emitter = await app.container.make('emitter') - const provider = await config.provider('provider', app) - const tokensProvider = config.tokens ? await config.tokens('tokens', app) : undefined - - /** - * Factory function needed by Authenticator to switch - * between guards and perform authentication - */ - return (ctx) => { - const guard = new SessionGuard(key, config, ctx, provider) - if (tokensProvider) { - guard.withRememberMeTokens(tokensProvider) - } - - return guard.withEmitter(emitter) - } - } -} - -/** - * Helpers to configure user and tokens provider - * for the session guard - */ -export const sessionProviders: { - users: { - lucid: ( - config: LucidUserProviderOptions & { - model: () => Promise<{ default: UserModel }> - } - ) => ConfigProvider>> - db: >( - config: DatabaseUserProviderOptions - ) => ConfigProvider> - } - tokens: { - db: (config: DatabaseRememberMeProviderOptions) => ConfigProvider - } -} = { - users: { - lucid: (config) => { - return async () => { - const { LucidSessionUserProvider } = await import('./user_providers/main.js') - return new LucidSessionUserProvider(config.model, config) - } - }, - db: (config) => { - return async (_, app) => { - const db = await app.container.make('lucid.db') - const hash = await app.container.make('hash') - const { DatabaseSessionUserProvider } = await import('./user_providers/main.js') - return new DatabaseSessionUserProvider(db, hash.use(), config) - } - }, - }, - tokens: { - db: (config) => { - return async (_, app) => { - const db = await app.container.make('lucid.db') - const { DatabaseRememberTokenProvider } = await import('./token_providers/main.js') - return new DatabaseRememberTokenProvider(db, config) - } - }, - }, -} diff --git a/src/session/user_providers/main.ts b/src/session/user_providers/main.ts deleted file mode 100644 index be10720..0000000 --- a/src/session/user_providers/main.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { SessionUserProviderContract } from '../types.js' -import type { LucidAuthenticatable } from '../../core/types.js' -import { LucidUserProvider } from '../../core/user_providers/lucid.js' -import { DatabaseUserProvider } from '../../core/user_providers/database.js' - -/** - * Using lucid models to find users for session - * auth - */ -export class LucidSessionUserProvider - extends LucidUserProvider - implements SessionUserProviderContract> {} - -/** - * Using database query builder to find users for - * session auth - */ -export class DatabaseSessionUserProvider> - extends DatabaseUserProvider - implements SessionUserProviderContract {} diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts new file mode 100644 index 0000000..eb8e080 --- /dev/null +++ b/tests/auth/authenticator.spec.ts @@ -0,0 +1,52 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { createEmitter } from '../helpers.js' +import { Authenticator } from '../../src/auth/authenticator.js' +import { FactoryUser } from '../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../factories/session_guard_factory.js' + +test.group('Authenticator', () => { + test('create authenticator with guards', async ({ assert, expectTypeOf }) => { + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + assert.instanceOf(authenticator, Authenticator) + expectTypeOf(authenticator.use).parameters.toMatchTypeOf<['web'?]>() + }) + + test('access guard using its name', async ({ assert, expectTypeOf }) => { + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + const webGuard = authenticator.use('web') + assert.strictEqual(webGuard, sessionGuard) + assert.strictEqual(authenticator.use('web'), authenticator.use('web')) + expectTypeOf(webGuard.user).toMatchTypeOf() + }) +}) diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts new file mode 100644 index 0000000..b94402f --- /dev/null +++ b/tests/auth/define_config.spec.ts @@ -0,0 +1,115 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { ApplicationService } from '@adonisjs/core/types' +import { AppFactory } from '@adonisjs/core/factories/app' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { HashManagerFactory } from '@adonisjs/core/factories/hash' + +import { createDatabase, createEmitter } from '../helpers.js' +import { AuthManager } from '../../src/auth/auth_manager.js' +import { Authenticator } from '../../src/auth/authenticator.js' +import { FactoryUser } from '../../factories/lucid_user_provider.js' +import { sessionGuard } from '../../src/guards/session/define_config.js' +import { defineConfig, providers } from '../../src/auth/define_config.js' +import { DatabaseUserProvider, LucidUserProvider } from '../../src/auth/user_providers/main.js' + +const BASE_URL = new URL('./', import.meta.url) +const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService +await app.init() + +test.group('Define config | providers', () => { + test('configure lucid provider', async ({ assert }) => { + const lucidConfigProvider = providers.lucid({ + model: async () => { + return { + default: FactoryUser, + } + }, + uids: ['email'], + }) + + const lucidProvider = await lucidConfigProvider.resolver(app) + assert.instanceOf(lucidProvider, LucidUserProvider) + }) + + test('configure db provider', async ({ assert }) => { + const dbConfigProvider = providers.db({ + table: 'users', + id: 'id', + passwordColumnName: 'password', + uids: ['email'], + }) + + app.container.bind('lucid.db', () => createDatabase()) + app.container.bind('hash', () => new HashManagerFactory().create()) + + const dbProvider = await dbConfigProvider.resolver(app) + assert.instanceOf(dbProvider, DatabaseUserProvider) + }) +}) + +test.group('Define config', () => { + test('define config for auth manager', async ({ assert }) => { + const lucidConfigProvider = providers.lucid({ + model: async () => { + return { + default: FactoryUser, + } + }, + uids: ['email'], + }) + + const authConfigProvider = defineConfig({ + default: 'web', + guards: { + web: sessionGuard({ + provider: lucidConfigProvider, + }), + }, + }) + + app.container.bind('emitter', () => createEmitter() as any) + + const authConfig = await authConfigProvider.resolver(app) + const authManager = new AuthManager(authConfig) + assert.instanceOf(authManager, AuthManager) + }) + + test('create auth object from auth manager', async ({ assert, expectTypeOf }) => { + const lucidConfigProvider = providers.lucid({ + model: async () => { + return { + default: FactoryUser, + } + }, + uids: ['email'], + }) + + const authConfigProvider = defineConfig({ + default: 'web', + guards: { + web: sessionGuard({ + provider: lucidConfigProvider, + }), + }, + }) + + app.container.bind('emitter', () => createEmitter() as any) + + const ctx = new HttpContextFactory().create() + const authConfig = await authConfigProvider.resolver(app) + const authManager = new AuthManager(authConfig) + const auth = authManager.createAuthenticator(ctx) + + assert.instanceOf(auth, Authenticator) + expectTypeOf(auth.use).parameters.toMatchTypeOf<['web'?]>() + }) +}) diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts index 94b1f82..c561c41 100644 --- a/tests/guards/session/authenticate.spec.ts +++ b/tests/guards/session/authenticate.spec.ts @@ -11,17 +11,17 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { RememberMeToken } from '../../../src/session/token.js' +import { RememberMeToken } from '../../../src/guards/session/token.js' import { FactoryUser } from '../../../factories/lucid_user_provider.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' -import { DatabaseRememberTokenProvider } from '../../../src/session/token_providers/main.js' +import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/main.js' import { + pEvent, timeTravel, parseCookies, createTables, defineCookies, createDatabase, - pEvent, createEmitter, } from '../../helpers.js' @@ -301,4 +301,25 @@ test.group('Session guard | authenticate', () => { assert.equal(authUser.id, user.id) }) }) + + test('silently authenticate using the check method', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [authFailed, authenticateCall] = await Promise.allSettled([ + pEvent(emitter, 'session_auth:authentication_failed'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.check() + }), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authenticateCall.status, 'fulfilled') + }) }) diff --git a/tests/guards/session/define_config.spec.ts b/tests/guards/session/define_config.spec.ts new file mode 100644 index 0000000..7aa04ec --- /dev/null +++ b/tests/guards/session/define_config.spec.ts @@ -0,0 +1,84 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { AppFactory } from '@adonisjs/core/factories/app' +import { ApplicationService } from '@adonisjs/core/types' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { HashManagerFactory } from '@adonisjs/core/factories/hash' + +import { providers } from '../../../index.js' +import { FactoryUser } from '../../../factories/main.js' +import { createDatabase, createEmitter } from '../../helpers.js' +import { SessionGuard } from '../../../src/guards/session/guard.js' +import { LucidUserProvider } from '../../../src/auth/user_providers/main.js' +import { sessionGuard, tokensProvider } from '../../../src/guards/session/define_config.js' + +const BASE_URL = new URL('./', import.meta.url) +const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService +await app.init() + +test.group('sessionGuard', () => { + test('configure session guard', async ({ assert, expectTypeOf }) => { + const sessionGuardProvider = sessionGuard({ + provider: providers.lucid({ + model: async () => { + return { + default: FactoryUser, + } + }, + uids: ['email'], + }), + }) + + app.container.bind('emitter', () => createEmitter() as any) + + const sessionFactory = await sessionGuardProvider.resolver('web', app) + assert.isFunction(sessionFactory) + expectTypeOf(sessionFactory).returns.toMatchTypeOf< + SessionGuard> + >() + + const ctx = new HttpContextFactory().create() + assert.instanceOf(sessionFactory(ctx), SessionGuard) + }) + + test('throw error when no provider is provided', async () => { + await sessionGuard({} as any).resolver('web', app) + }).throws('Invalid user provider defined on "web" guard') + + test('configure session guard with tokens provider', async ({ assert, expectTypeOf }) => { + const sessionGuardProvider = sessionGuard({ + provider: providers.lucid({ + model: async () => { + return { + default: FactoryUser, + } + }, + uids: ['email'], + }), + tokens: tokensProvider.db({ + table: 'remember_me_tokens', + }), + }) + + app.container.bind('emitter', () => createEmitter() as any) + app.container.bind('lucid.db', () => createDatabase()) + app.container.bind('hash', () => new HashManagerFactory().create()) + + const sessionFactory = await sessionGuardProvider.resolver('web', app) + assert.isFunction(sessionFactory) + expectTypeOf(sessionFactory).returns.toMatchTypeOf< + SessionGuard> + >() + + const ctx = new HttpContextFactory().create() + assert.instanceOf(sessionFactory(ctx), SessionGuard) + }) +}) diff --git a/tests/guards/session/login.spec.ts b/tests/guards/session/login.spec.ts index 3b40d8d..95f1b0a 100644 --- a/tests/guards/session/login.spec.ts +++ b/tests/guards/session/login.spec.ts @@ -11,10 +11,10 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { RememberMeToken } from '../../../src/session/token.js' +import { RememberMeToken } from '../../../src/guards/session/token.js' import { FactoryUser } from '../../../factories/lucid_user_provider.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' -import { DatabaseRememberTokenProvider } from '../../../src/session/token_providers/main.js' +import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/main.js' import { createDatabase, createEmitter, createTables, pEvent, parseCookies } from '../../helpers.js' test.group('Session guard | login', () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 9cf36be..98a42ea 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -22,7 +22,7 @@ import setCookieParser, { CookieMap } from 'set-cookie-parser' import { LoggerFactory } from '@adonisjs/core/factories/logger' import { EncryptionFactory } from '@adonisjs/core/factories/encryption' -import { SessionGuardEvents } from '../src/session/types.js' +import { SessionGuardEvents } from '../src/guards/session/types.js' import { FactoryUser } from '../factories/lucid_user_provider.js' /** From 8195845770a394b8d61b3dc02e4a44ba46d59e46 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 23 Oct 2023 14:03:34 +0530 Subject: [PATCH 09/96] chore: export auth provider and auth service --- package.json | 3 +++ providers/auth_provider.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index efa73eb..ab32f32 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ ".": "./build/index.js", "./types": "./build/src/types/main.js", + "./auth_provider": "./build/providers/auth_provider.js", + "./services/main": "./build/services/auth.js", + "./core/token": "./build/src/core/token.js", "./core/guard_user": "./build/src/core/guard_user.js", "./core/user_providers/*": "./build/src/core/user_providers/*.js", diff --git a/providers/auth_provider.ts b/providers/auth_provider.ts index 875416d..fe6df01 100644 --- a/providers/auth_provider.ts +++ b/providers/auth_provider.ts @@ -11,8 +11,8 @@ import { configProvider } from '@adonisjs/core' import { RuntimeException } from '@poppinss/utils' import type { ApplicationService } from '@adonisjs/core/types' -import { AuthManager } from '../src/auth/auth_manager.js' import type { AuthService } from '../src/auth/types.js' +import { AuthManager } from '../src/auth/auth_manager.js' declare module '@adonisjs/core/types' { export interface ContainerBindings { From 4f5421b16a51081c141be0a5a528efdfbce7e81e Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 24 Oct 2023 10:11:35 +0530 Subject: [PATCH 10/96] feat: wip configure process --- configure.ts | 47 ++++++++++++++++++++++++ index.ts | 4 +- package.json | 4 +- stubs/config.stub | 35 ++++++++++++++++++ stubs/config/auth_middleware.stub | 12 ++++++ stubs/main.ts | 12 ++++++ tests/auth/configure.spec.ts | 61 +++++++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 configure.ts create mode 100644 stubs/config.stub create mode 100644 stubs/config/auth_middleware.stub create mode 100644 stubs/main.ts create mode 100644 tests/auth/configure.spec.ts diff --git a/configure.ts b/configure.ts new file mode 100644 index 0000000..bbcae4c --- /dev/null +++ b/configure.ts @@ -0,0 +1,47 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type Configure from '@adonisjs/core/commands/configure' + +/** + * Configures the user provider to use for finding + * users + */ +async function configureProvider(command: Configure) { + const provider = await command.prompt.choice('Select the user provider you want to use', [ + { + name: 'lucid', + message: 'Lucid models', + }, + { + name: 'db', + message: 'Database query builder', + }, + ]) + + /** + * Publish config file + */ + await command.publishStub('config.stub', { provider }) +} + +/** + * Configures the auth package + */ +export async function configure(command: Configure) { + await configureProvider(command) + const codemods = await command.createCodemods() + + /** + * Register provider + */ + await codemods.updateRcFile((rcFile) => { + rcFile.addProvider('@adonisjs/auth/auth_provider') + }) +} diff --git a/index.ts b/index.ts index fd0f64e..8e86abd 100644 --- a/index.ts +++ b/index.ts @@ -7,8 +7,10 @@ * file that was distributed with this source code. */ +export { configure } from './configure.js' +export { stubsRoot } from './stubs/main.js' export * as errors from './src/auth/errors.js' export * as symbols from './src/auth/symbols.js' -export { Authenticator } from './src/auth/authenticator.js' export { AuthManager } from './src/auth/auth_manager.js' +export { Authenticator } from './src/auth/authenticator.js' export { defineConfig, providers } from './src/auth/define_config.js' diff --git a/package.json b/package.json index ab32f32..f4f5500 100644 --- a/package.json +++ b/package.json @@ -23,16 +23,13 @@ "exports": { ".": "./build/index.js", "./types": "./build/src/types/main.js", - "./auth_provider": "./build/providers/auth_provider.js", "./services/main": "./build/services/auth.js", - "./core/token": "./build/src/core/token.js", "./core/guard_user": "./build/src/core/guard_user.js", "./core/user_providers/*": "./build/src/core/user_providers/*.js", "./core/token_providers/*": "./build/src/core/token_providers/*.js", "./types/core": "./build/src/core/types.js", - "./session": "./build/src/guards/session/main.js", "./types/session": "./build/src/guards/session/types.js" }, @@ -68,6 +65,7 @@ "url": "https://github.com/adonisjs/auth/issues" }, "devDependencies": { + "@adonisjs/assembler": "^6.1.3-25", "@adonisjs/core": "^6.1.5-30", "@adonisjs/eslint-config": "^1.1.8", "@adonisjs/lucid": "^19.0.0-3", diff --git a/stubs/config.stub b/stubs/config.stub new file mode 100644 index 0000000..46d2f68 --- /dev/null +++ b/stubs/config.stub @@ -0,0 +1,35 @@ +{{{ + exports({ to: app.configPath('auth.ts') }) +}}} +import { defineConfig, providers } from '@adonisjs/auth' +{{#if provider === 'lucid'}} +/** + * Using the "models/user" model to find users during + * login and authentication + */ +const userProvider = providers.lucid({ + model: () => import('#models/user'), + uids: ['email'], +}) +{{/if}} +{{#if provider === 'db'}} +/** + * Using Lucid query builder to directly query the database + * to find users during login and authentication. + */ +const userProvider = providers.db({ + table: 'users', + passwordColumnName: 'password', + id: 'id', + uids: ['email'] +}) +{{/if}} + +const authConfig = defineConfig({ + guards: {} +}) + +export default authConfig +declare module '@adonisjs/auth/types' { + interface Authenticators extends InferAuthenticators {} +} diff --git a/stubs/config/auth_middleware.stub b/stubs/config/auth_middleware.stub new file mode 100644 index 0000000..ad1bb2d --- /dev/null +++ b/stubs/config/auth_middleware.stub @@ -0,0 +1,12 @@ +import { HttpContext } from '@adonisjs/core/http' +import { NextFn } from '@adonisjs/core/http/types' + +type AuthMiddlewareOptions = { + guards?: (keyof Authenticators)[] +} + +export default class AuthMiddleware { + async handle({ auth }: HttpContext, next: NextFn, options?: AuthMiddlewareOptions) { + return next() + } +} diff --git a/stubs/main.ts b/stubs/main.ts new file mode 100644 index 0000000..ce97e2d --- /dev/null +++ b/stubs/main.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { getDirname } from '@poppinss/utils' + +export const stubsRoot = getDirname(import.meta.url) diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.spec.ts new file mode 100644 index 0000000..eaa1f4e --- /dev/null +++ b/tests/auth/configure.spec.ts @@ -0,0 +1,61 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// import { test } from '@japa/runner' +// import { fileURLToPath } from 'node:url' +// import { IgnitorFactory } from '@adonisjs/core/factories' +// import Configure from '@adonisjs/core/commands/configure' + +// const BASE_URL = new URL('./tmp/', import.meta.url) + +// test.group('Configure', (group) => { +// group.each.setup(({ context }) => { +// context.fs.baseUrl = BASE_URL +// context.fs.basePath = fileURLToPath(BASE_URL) +// }) + +// test('create config file and register provider', async ({ fs, assert }) => { +// const ignitor = new IgnitorFactory() +// .withCoreProviders() +// .withCoreConfig() +// .create(BASE_URL, { +// importer: (filePath) => { +// if (filePath.startsWith('./') || filePath.startsWith('../')) { +// return import(new URL(filePath, BASE_URL).href) +// } + +// return import(filePath) +// }, +// }) + +// // await fs.create('.env', '') +// // await fs.createJson('tsconfig.json', {}) +// // await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) +// // await fs.create('start/kernel.ts', `router.use([])`) +// // await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) + +// const app = ignitor.createApp('web') +// await app.init() +// await app.boot() + +// const ace = await app.container.make('ace') +// const command = await ace.create(Configure, ['../../../index.js']) +// await command.exec() + +// // await assert.fileExists('config/session.ts') +// // await assert.fileExists('adonisrc.ts') +// // await assert.fileContains('adonisrc.ts', '@adonisjs/session/session_provider') +// // await assert.fileContains('config/session.ts', 'defineConfig') +// // await assert.fileContains('.env', 'SESSION_DRIVER=cookie') +// // await assert.fileContains( +// // 'start/env.ts', +// // `SESSION_DRIVER: Env.schema.enum(['cookie', 'redis', 'file', 'memory'] as const)` +// // ) +// }).timeout(60 * 1000) +// }) From 2ccd96779a7da3a5d5a717d03261e822d0ae8791 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 24 Oct 2023 10:14:30 +0530 Subject: [PATCH 11/96] chore: update peer dependencies range --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index f4f5500..e35f28d 100644 --- a/package.json +++ b/package.json @@ -132,9 +132,9 @@ "@poppinss/utils": "^6.5.0" }, "peerDependencies": { - "@adonisjs/core": "^6.1.5-26", - "@adonisjs/lucid": "^19.0.0-2", - "@adonisjs/session": "^7.0.0-11" + "@adonisjs/core": "^6.1.5-30", + "@adonisjs/lucid": "^19.0.0-3", + "@adonisjs/session": "^7.0.0-13" }, "peerDependenciesMeta": { "@adonisjs/lucid": { From 2e8c3b360dad2241890488e354465193e291c1f2 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 24 Oct 2023 10:19:02 +0530 Subject: [PATCH 12/96] refactor: list of published files --- package.json | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e35f28d..4196cb8 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,11 @@ "build/configure.d.ts", "build/index.js", "build/index.d.ts", - "build/src", - "build/services", - "build/providers", "build/factories", - "build/stubs", - "build/index.d.ts", - "build/index.js" + "build/providers", + "build/services", + "build/src", + "build/stubs" ], "engines": { "node": ">=18.16.0" From fe8abf917a690102c873e9abe1e6605eaa710f4e Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 24 Oct 2023 10:22:52 +0530 Subject: [PATCH 13/96] chore(release): 9.0.0-0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4196cb8..55400d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "8.2.3", + "version": "9.0.0-0", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From ed30d5ed9fb56de70c0891409c6c0459d24e3e89 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 24 Oct 2023 11:04:13 +0530 Subject: [PATCH 14/96] fix: broken export path for types --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 55400d4..b0c02f2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "exports": { ".": "./build/index.js", - "./types": "./build/src/types/main.js", + "./types": "./build/src/auth/types.js", "./auth_provider": "./build/providers/auth_provider.js", "./services/main": "./build/services/auth.js", "./core/token": "./build/src/core/token.js", From ab2ef9d1ffb42f02663cdcd6984af9037ed61124 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 24 Oct 2023 11:05:49 +0530 Subject: [PATCH 15/96] fix: broken infer types helper --- src/auth/types.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/auth/types.ts b/src/auth/types.ts index 23aa4f2..c22a080 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -51,9 +51,12 @@ export interface Authenticators {} /** * Infer authenticators from the auth config */ -export type InferAuthenticators> = Awaited< - ReturnType -> +export type InferAuthenticators< + Config extends ConfigProvider<{ + default: unknown + guards: unknown + }>, +> = Awaited>['guards'] /** * Auth service is a singleton instance of the AuthManager @@ -61,7 +64,9 @@ export type InferAuthenticators> = Awaite * app. */ export interface AuthService - extends AuthManager {} + extends AuthManager< + Authenticators extends Record ? Authenticators : never + > {} /** * Config provider for exporting guard From db93550f051b7e013565ab17ccc8381b7b159a05 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 10:13:00 +0530 Subject: [PATCH 16/96] feat: proper error handling --- package.json | 1 + src/auth/auth_manager.ts | 7 + src/auth/authenticator.ts | 78 ++++++++ src/auth/errors.ts | 220 ++++++++++++++++++++-- src/auth/types.ts | 38 +++- src/core/token.ts | 10 +- src/guards/session/guard.ts | 128 +++++++++++-- src/guards/session/types.ts | 1 - tests/auth/auth_manager.spec.ts | 35 ++++ tests/auth/authenticator.spec.ts | 66 ++++++- tests/auth/errors.spec.ts | 215 +++++++++++++++++++++ tests/core/token.spec.ts | 8 +- tests/guards/session/attempt.spec.ts | 98 ++++++++++ tests/guards/session/authenticate.spec.ts | 48 +++++ tests/guards/session/login.spec.ts | 2 +- tests/guards/session/login_via_id.spec.ts | 69 +++++++ 16 files changed, 981 insertions(+), 43 deletions(-) create mode 100644 tests/auth/auth_manager.spec.ts create mode 100644 tests/auth/errors.spec.ts create mode 100644 tests/guards/session/attempt.spec.ts create mode 100644 tests/guards/session/login_via_id.spec.ts diff --git a/package.json b/package.json index b0c02f2..e792a50 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@adonisjs/assembler": "^6.1.3-25", "@adonisjs/core": "^6.1.5-30", "@adonisjs/eslint-config": "^1.1.8", + "@adonisjs/i18n": "^2.0.0-6", "@adonisjs/lucid": "^19.0.0-3", "@adonisjs/prettier-config": "^1.1.8", "@adonisjs/session": "^7.0.0-13", diff --git a/src/auth/auth_manager.ts b/src/auth/auth_manager.ts index 7ad9dfa..ca0a8b8 100644 --- a/src/auth/auth_manager.ts +++ b/src/auth/auth_manager.ts @@ -25,6 +25,13 @@ export class AuthManager> { guards: KnownGuards } + /** + * Name of the default guard + */ + get defaultGuard() { + return this.#config.default + } + constructor(config: { default: keyof KnownGuards; guards: KnownGuards }) { this.#config = config } diff --git a/src/auth/authenticator.ts b/src/auth/authenticator.ts index 1886abf..5c03236 100644 --- a/src/auth/authenticator.ts +++ b/src/auth/authenticator.ts @@ -11,12 +11,19 @@ import type { HttpContext } from '@adonisjs/core/http' import debug from './debug.js' import type { GuardFactory } from './types.js' +import { AuthenticationException } from './errors.js' /** * Authenticator is an HTTP request specific implementation for using * guards to login users and authenticate requests. */ export class Authenticator> { + /** + * Name of the guard using which the request has + * been authenticated + */ + #authenticatedViaGuard?: keyof KnownGuards + /** * Reference to HTTP context */ @@ -35,6 +42,46 @@ export class Authenticator> { */ #guardsCache: Partial> = {} + /** + * Name of the default guard + */ + get defaultGuard(): keyof KnownGuards { + return this.#config.default + } + + /** + * Reference to the guard using which the current + * request has been authenticated. + */ + get authenticatedViaGuard(): keyof KnownGuards | undefined { + return this.#authenticatedViaGuard + } + + /** + * A boolean to know if the current request has + * been authenticated + */ + get isAuthenticated(): boolean { + return this.use(this.#authenticatedViaGuard || this.defaultGuard).isAuthenticated + } + + /** + * Reference to the currently authenticated user + */ + get user(): { + [K in keyof KnownGuards]: ReturnType['user'] + }[keyof KnownGuards] { + return this.use(this.#authenticatedViaGuard || this.defaultGuard).user + } + + /** + * Whether or not the authentication has been attempted + * during the current request + */ + get authenticationAttempted(): boolean { + return this.use(this.#authenticatedViaGuard || this.defaultGuard).authenticationAttempted + } + constructor(ctx: HttpContext, config: { default: keyof KnownGuards; guards: KnownGuards }) { this.#ctx = ctx this.#config = config @@ -68,4 +115,35 @@ export class Authenticator> { return guardInstance as ReturnType } + + /** + * Authenticate the request using all of the mentioned + * guards or the default guard. + * + * The authentication process will stop after any of the + * mentioned guards is able to authenticate the request + * successfully. + * + * Otherwise, "AuthenticationException" will be raised. + */ + async authenticateUsing(guards?: (keyof KnownGuards)[]) { + const guardsToUse = guards || [this.defaultGuard] + let lastUsedGuardDriver: string | undefined + + for (let guardName of guardsToUse) { + debug('attempting to authenticate using guard "%s"', guardName) + const guard = this.use(guardName) + lastUsedGuardDriver = guard.driverName + + if (await guard.check()) { + this.#authenticatedViaGuard = guardName + return true + } + } + + throw new AuthenticationException('Unauthorized access', { + code: 'E_UNAUTHORIZED_ACCESS', + guardDriverName: lastUsedGuardDriver!, + }) + } } diff --git a/src/auth/errors.ts b/src/auth/errors.ts index 2754dd1..b28d6c3 100644 --- a/src/auth/errors.ts +++ b/src/auth/errors.ts @@ -7,22 +7,216 @@ * file that was distributed with this source code. */ -import { createError } from '@poppinss/utils' +import type { I18n } from '@adonisjs/i18n' +import { Exception } from '@poppinss/utils' +import { HttpContext } from '@adonisjs/core/http' /** - * Invalid token provided + * Authentication exception is raised when an attempt is + * made to authenticate an HTTP request */ -export const E_INVALID_AUTH_TOKEN = createError( - 'Invalid or expired token value', - 'E_INVALID_AUTH_TOKEN', - 401 -) +export class AuthenticationException extends Exception { + /** + * Raises authentication exception when session guard + * is unable to authenticate the request + */ + static E_INVALID_AUTH_SESSION() { + return new AuthenticationException('Invalid or expired authentication session', { + code: 'E_INVALID_AUTH_SESSION', + status: 401, + guardDriverName: 'session', + }) + } + + guardDriverName: string + redirectTo?: string + identifier = 'auth.authenticate' + + constructor( + message: string, + options: ErrorOptions & { + guardDriverName: string + redirectTo?: string + code?: string + status?: number + } + ) { + super(message, options) + this.guardDriverName = options.guardDriverName + this.redirectTo = options.redirectTo + } + + /** + * Returns the message to be sent in the HTTP response. + * Feel free to override this method and return a custom + * response. + */ + getResponseMessage(error: AuthenticationException, ctx: HttpContext) { + if ('i18n' in ctx) { + return (ctx.i18n as I18n).t(error.identifier, {}, error.message) + } + return error.message + } + + /** + * A collection of authentication exception + * renderers to render the exception to a + * response. + * + * The collection is a key-value pair, where the + * key is the guard driver name and value is + * a factory function to respond to the + * request. + */ + renderers: Record< + string, + (message: string, error: AuthenticationException, ctx: HttpContext) => Promise | void + > = { + session: (message, error, ctx) => { + switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { + case 'html': + case null: + ctx.session.flashExcept(['_csrf']) + ctx.session.flash({ errors: { [error.identifier]: [message] } }) + ctx.response.redirect(error.redirectTo || '/', true) + break + case 'json': + ctx.response.status(error.status).send({ + errors: [ + { + message, + }, + ], + }) + break + case 'application/vnd.api+json': + ctx.response.status(error.status).send({ + errors: [ + { + code: error.identifier, + title: message, + }, + ], + }) + break + } + }, + } + + /** + * Self handles the auth exception and converts it to an + * HTTP response + */ + async handle(error: AuthenticationException, ctx: HttpContext) { + const renderer = this.renderers[this.guardDriverName] + const message = error.getResponseMessage(error, ctx) + + if (!renderer) { + return ctx.response.status(error.status).send(message) + } + + return renderer(message, error, ctx) + } +} /** - * The user session is invalid + * Invalid credentials exception is raised when unable + * to verify user credentials during login */ -export const E_INVALID_AUTH_SESSION = createError( - 'Invalid or expired authentication session', - 'E_INVALID_AUTH_SESSION', - 401 -) +export class InvalidCredentialsException extends Exception { + static message: string = 'Invalid credentials' + static code: string = 'E_INVALID_CREDENTIALS' + + static E_INVALID_CREDENTIALS(guardDriverName: string) { + return new InvalidCredentialsException(InvalidCredentialsException.message, { + guardDriverName, + }) + } + + guardDriverName: string + identifier = 'auth.login' + + constructor( + message: string, + options: ErrorOptions & { + guardDriverName: string + code?: string + status?: number + } + ) { + super(message, options) + this.guardDriverName = options.guardDriverName + } + + /** + * Returns the message to be sent in the HTTP response. + * Feel free to override this method and return a custom + * response. + */ + getResponseMessage(error: InvalidCredentialsException, ctx: HttpContext) { + if ('i18n' in ctx) { + return (ctx.i18n as I18n).t(this.identifier, {}, error.message) + } + return error.message + } + + /** + * A collection of authentication exception + * renderers to render the exception to a + * response. + * + * The collection is a key-value pair, where the + * key is the guard driver name and value is + * a factory function to respond to the + * request. + */ + renderers: Record< + string, + (message: string, error: InvalidCredentialsException, ctx: HttpContext) => Promise | void + > = { + session: (message, error, ctx) => { + switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { + case 'html': + case null: + ctx.session.flashExcept(['_csrf']) + ctx.session.flash({ errors: { [this.identifier]: [message] } }) + ctx.response.redirect().withQs().back() + break + case 'json': + ctx.response.status(error.status).send({ + errors: [ + { + message: message, + }, + ], + }) + break + case 'application/vnd.api+json': + ctx.response.status(error.status).send({ + errors: [ + { + code: this.identifier, + title: message, + }, + ], + }) + break + } + }, + } + + /** + * Self handles the auth exception and converts it to an + * HTTP response + */ + async handle(error: InvalidCredentialsException, ctx: HttpContext) { + const renderer = this.renderers[this.guardDriverName] + const message = this.getResponseMessage(error, ctx) + + if (!renderer) { + return ctx.response.status(error.status).send(message) + } + + return renderer(message, error, ctx) + } +} diff --git a/src/auth/types.ts b/src/auth/types.ts index c22a080..79cb15b 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -7,7 +7,6 @@ * file that was distributed with this source code. */ -import type { Emitter } from '@adonisjs/core/events' import type { HttpContext } from '@adonisjs/core/http' import type { ApplicationService, ConfigProvider } from '@adonisjs/core/types' @@ -19,20 +18,45 @@ import type { GUARD_KNOWN_EVENTS } from './symbols.js' */ export interface GuardContract { /** - * Reference to the user type + * Reference to the currently authenticated user */ user?: User /** - * Aymbol for infer the events emitted by a specific - * guard + * A boolean to know if the current request has + * been authenticated */ - [GUARD_KNOWN_EVENTS]: unknown + isAuthenticated: boolean + + /** + * Whether or not the authentication has been attempted + * during the current request + */ + authenticationAttempted: boolean + + /** + * Check if the current request has been + * authenticated without throwing an + * exception + */ + check(): Promise /** - * Accept an instance of the emitter to emit events + * Authenticates the current request and throws + * an exception if the request is not authenticated. */ - withEmitter(emitter: Emitter): this + authenticate(): Promise + + /** + * A unique name for the guard driver + */ + driverName: string + + /** + * Aymbol for infer the events emitted by a specific + * guard + */ + [GUARD_KNOWN_EVENTS]: unknown } /** diff --git a/src/core/token.ts b/src/core/token.ts index 4b03f02..99f4b86 100644 --- a/src/core/token.ts +++ b/src/core/token.ts @@ -11,7 +11,6 @@ import { createHash } from 'node:crypto' import string from '@adonisjs/core/helpers/string' import { base64, safeEqual } from '@adonisjs/core/helpers' -import * as errors from '../auth/errors.js' import type { TokenContract } from './types.js' /** @@ -108,17 +107,20 @@ export abstract class Token implements TokenContract { /** * Decodes a publicly shared token and return the series * and the token value from it. + * + * Returns null when unable to decode the token because of + * invalid format or encoding. */ - static decode(value: string) { + static decode(value: string): null | { series: string; value: string } { const [series, ...tokenValue] = value.split('.') if (!series || tokenValue.length === 0) { - throw new errors.E_INVALID_AUTH_TOKEN() + return null } const decodedSeries = base64.urlDecode(series) const decodedValue = base64.urlDecode(tokenValue.join('.')) if (!decodedSeries || !decodedValue) { - throw new errors.E_INVALID_AUTH_TOKEN() + return null } return { diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts index c8f0488..91d4d59 100644 --- a/src/guards/session/guard.ts +++ b/src/guards/session/guard.ts @@ -15,9 +15,9 @@ import { Exception, RuntimeException } from '@poppinss/utils' import debug from '../../auth/debug.js' import { RememberMeToken } from './token.js' -import * as errors from '../../auth/errors.js' import type { GuardContract } from '../../auth/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../auth/symbols.js' +import { AuthenticationException, InvalidCredentialsException } from '../../auth/errors.js' import type { SessionGuardEvents, SessionGuardConfig, @@ -66,6 +66,11 @@ export class SessionGuard> + /** + * Driver name of the guard + */ + driverName: 'session' = 'session' + /** * Whether or not the authentication has been attempted * during the current request @@ -161,7 +166,7 @@ export class SessionGuard { + debug('session_guard: attempting to verify credentials for uid "%s"', uid) + + /** + * Attempt to find a user by the uid and raise + * error when unable to find one + */ + const providerUser = await this.#userProvider.findByUid(uid) + if (!providerUser) { + this.#loginFailed(InvalidCredentialsException.E_INVALID_CREDENTIALS(this.driverName), null) + } + + /** + * Raise error when unable to verify password + */ + const user = providerUser.getOriginal() + + /** + * Raise error when unable to verify password + */ + if (!(await providerUser.verifyPassword(password))) { + this.#loginFailed(InvalidCredentialsException.E_INVALID_CREDENTIALS(this.driverName), user) + } + + /** + * Notify credentials have been verified + */ + if (this.#emitter) { + this.#emitter.emit('session_auth:credentials_verified', { + uid, + user, + }) + } + + return user + } + + /** + * Attempt to login a user after verifying their + * credentials. + */ + async attempt(uid: string, password: string): Promise { + const user = await this.verifyCredentials(uid, password) + return this.login(user) + } + + /** + * Attempt to login a user using the user id. The + * user will be first fetched from the db before + * marking them as logged-in + */ + async loginViaId(id: string | number): Promise { + debug('session_guard: attempting to login user via id "%s"', id) + + const providerUser = await this.#userProvider.findById(id) + if (!providerUser) { + this.#loginFailed(InvalidCredentialsException.E_INVALID_CREDENTIALS(this.driverName), null) + } + + return this.login(providerUser.getOriginal()) + } + /** * Login a user using the user object. */ @@ -287,7 +375,7 @@ export class SessionGuard { if (this.authenticationAttempted) { return this.getUserOrFail() } @@ -319,7 +407,10 @@ export class SessionGuard = { 'session_auth:credentials_verified': { uid: string user: User - password: string } /** diff --git a/tests/auth/auth_manager.spec.ts b/tests/auth/auth_manager.spec.ts new file mode 100644 index 0000000..c2d01b1 --- /dev/null +++ b/tests/auth/auth_manager.spec.ts @@ -0,0 +1,35 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { createEmitter } from '../helpers.js' +import { AuthManager } from '../../src/auth/auth_manager.js' +import { Authenticator } from '../../src/auth/authenticator.js' +import { SessionGuardFactory } from '../../factories/session_guard_factory.js' + +test.group('Auth manager', () => { + test('create authenticator from auth manager', async ({ assert, expectTypeOf }) => { + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + + const authManager = new AuthManager({ + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + assert.equal(authManager.defaultGuard, 'web') + assert.instanceOf(authManager.createAuthenticator(ctx), Authenticator) + expectTypeOf(authManager.createAuthenticator(ctx).use).parameters.toMatchTypeOf<['web'?]>() + }) +}) diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index eb8e080..e7294d6 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -9,10 +9,11 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { createEmitter } from '../helpers.js' import { Authenticator } from '../../src/auth/authenticator.js' import { FactoryUser } from '../../factories/lucid_user_provider.js' +import { createDatabase, createEmitter, createTables } from '../helpers.js' import { SessionGuardFactory } from '../../factories/session_guard_factory.js' test.group('Authenticator', () => { @@ -46,7 +47,70 @@ test.group('Authenticator', () => { const webGuard = authenticator.use('web') assert.strictEqual(webGuard, sessionGuard) + assert.equal(authenticator.defaultGuard, 'web') + assert.equal(webGuard.driverName, 'session') assert.strictEqual(authenticator.use('web'), authenticator.use('web')) expectTypeOf(webGuard.user).toMatchTypeOf() }) + + test('authenticate using the default guard', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + await sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + await authenticator.authenticateUsing() + }) + + assert.instanceOf(authenticator.user, FactoryUser) + assert.equal(authenticator.user!.id, user.id) + expectTypeOf(authenticator.user).toMatchTypeOf() + assert.equal(authenticator.authenticatedViaGuard, 'web') + assert.isTrue(authenticator.isAuthenticated) + assert.isTrue(authenticator.authenticationAttempted) + }) + + test('throw error when unable to authenticate', async ({ assert }) => { + assert.plan(4) + + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + try { + await sessionMiddleware.handle(ctx, async () => { + await authenticator.authenticateUsing() + }) + } catch (error) { + assert.equal(error.message, 'Unauthorized access') + assert.equal(error.guardDriverName, 'session') + } + + assert.isFalse(authenticator.isAuthenticated) + assert.isTrue(authenticator.authenticationAttempted) + }) }) diff --git a/tests/auth/errors.spec.ts b/tests/auth/errors.spec.ts new file mode 100644 index 0000000..5fee0a8 --- /dev/null +++ b/tests/auth/errors.spec.ts @@ -0,0 +1,215 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' +import { AuthenticationException, InvalidCredentialsException } from '../../src/auth/errors.js' + +test.group('Errors | AuthenticationException', () => { + test('handle session guard exception with a redirect', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = new AuthenticationException('Unauthorized access', { + guardDriverName: 'session', + }) + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.session.responseFlashMessages.all(), { + errors: { 'auth.authenticate': ['Unauthorized access'] }, + input: {}, + }) + assert.equal(ctx.response.getHeader('location'), '/') + }) + + test('handle session guard exception with a redirect to a custom location', async ({ + assert, + }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = new AuthenticationException('Unauthorized access', { + guardDriverName: 'session', + redirectTo: '/login', + }) + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.session.responseFlashMessages.all(), { + errors: { 'auth.authenticate': ['Unauthorized access'] }, + input: {}, + }) + assert.equal(ctx.response.getHeader('location'), '/login') + }) + + test('handle session guard exception with JSON response', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = new AuthenticationException('Unauthorized access', { + guardDriverName: 'session', + redirectTo: '/login', + }) + + const ctx = new HttpContextFactory().create() + + /** + * The accept header will force a JSON response + */ + ctx.request.request.headers.accept = 'application/json' + + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + message: 'Unauthorized access', + }, + ], + }) + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) + }) + + test('handle session guard exception with JSONAPI response', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = new AuthenticationException('Unauthorized access', { + guardDriverName: 'session', + redirectTo: '/login', + }) + + const ctx = new HttpContextFactory().create() + + /** + * The accept header will force a JSONAPI response + */ + ctx.request.request.headers.accept = 'application/vnd.api+json' + + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + title: 'Unauthorized access', + code: 'auth.authenticate', + }, + ], + }) + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) + }) + + test('send plain text response when there is no renderer for a guard', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = new AuthenticationException('Unauthorized access', { + guardDriverName: 'foo', + redirectTo: '/login', + status: 401, + }) + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.equal(ctx.response.getStatus(), 401) + assert.equal(ctx.response.getBody(), 'Unauthorized access') + }) +}) + +test.group('Errors | InvalidCredentialsException', () => { + test('handle session guard exception with a redirect', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('session') + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.session.responseFlashMessages.all(), { + errors: { 'auth.login': ['Invalid credentials'] }, + input: {}, + }) + assert.equal(ctx.response.getHeader('location'), '/') + }) + + test('handle session guard exception with a JSON response', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('session') + + const ctx = new HttpContextFactory().create() + + /** + * The accept header will force a JSON response + */ + ctx.request.request.headers.accept = 'application/json' + + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + message: 'Invalid credentials', + }, + ], + }) + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) + }) + + test('handle session guard exception with a JSONAPI response', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('session') + + const ctx = new HttpContextFactory().create() + + /** + * The accept header will force a JSON response + */ + ctx.request.request.headers.accept = 'application/vnd.api+json' + + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + title: 'Invalid credentials', + code: 'auth.login', + }, + ], + }) + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) + }) + + test('respond with plain text when there is no renderer for guard', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('foo') + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.equal(ctx.response.getBody(), 'Invalid credentials') + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) + }) +}) diff --git a/tests/core/token.spec.ts b/tests/core/token.spec.ts index abc02a7..611bf9f 100644 --- a/tests/core/token.spec.ts +++ b/tests/core/token.spec.ts @@ -38,7 +38,7 @@ test.group('Token', () => { const { series, value, hash } = TestToken.seed() const token = new TestToken(series, value, hash) - assert.isTrue(token.verify(TestToken.decode(value).value)) + assert.isTrue(token.verify(TestToken.decode(value)!.value)) }) test('set token metadata', ({ assert }) => { @@ -49,11 +49,11 @@ test.group('Token', () => { }) test('decode valid and invalid tokens', ({ assert }) => { - assert.throws(() => TestToken.decode('foo'), 'Invalid or expired token value') - assert.throws(() => TestToken.decode('foo.bar'), 'Invalid or expired token value') + assert.isNull(TestToken.decode('foo')) + assert.isNull(TestToken.decode('foo.bar')) const { series, value } = TestToken.seed() - const decoded = TestToken.decode(value) + const decoded = TestToken.decode(value)! assert.equal(series, decoded.series) }) }) diff --git a/tests/guards/session/attempt.spec.ts b/tests/guards/session/attempt.spec.ts new file mode 100644 index 0000000..fc2e0f0 --- /dev/null +++ b/tests/guards/session/attempt.spec.ts @@ -0,0 +1,98 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' + +test.group('Session guard | attempt', () => { + test('login user using email and password', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [credentialsVerified] = await Promise.all([ + pEvent(emitter, 'session_auth:credentials_verified'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.attempt(user.email, 'secret') + }), + ]) + + assert.strictEqual(credentialsVerified?.user, sessionGuard.user) + assert.equal(credentialsVerified?.uid, sessionGuard.user!.email) + assert.equal(sessionGuard.user!.id, user.id) + // since the attempt method will fetch from db + assert.notStrictEqual(sessionGuard.user, user) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isFalse(sessionGuard.isAuthenticated) + assert.isFalse(sessionGuard.authenticationAttempted) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + }) + + test('throw error when password is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [loginFailed, attemptResult] = await Promise.allSettled([ + pEvent(emitter, 'session_auth:login_failed'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.attempt(user.email, 'foo') + }), + ]) + + assert.equal(attemptResult.status, 'rejected') + assert.equal(loginFailed.status, 'fulfilled') + if (attemptResult.status === 'rejected') { + assert.equal(attemptResult.reason.message, 'Invalid credentials') + } + }) + + test('throw error when unable to find the user by uid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [loginFailed, attemptResult] = await Promise.allSettled([ + pEvent(emitter, 'session_auth:login_failed'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.attempt('foo', 'foo') + }), + ]) + + assert.equal(attemptResult.status, 'rejected') + assert.equal(loginFailed.status, 'fulfilled') + if (attemptResult.status === 'rejected') { + assert.equal(attemptResult.reason.message, 'Invalid credentials') + } + }) +}) diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts index c561c41..d72be8a 100644 --- a/tests/guards/session/authenticate.spec.ts +++ b/tests/guards/session/authenticate.spec.ts @@ -200,6 +200,28 @@ test.group('Session guard | authenticate', () => { assert.notEqual(parsedCookies.remember_web.value, token.value) }) + test('throw error when remember me token is invalid', async () => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: 'foobar', + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }) + }).throws('Invalid or expired authentication session') + test('throw error when remember me token has been expired', async () => { const db = await createDatabase() await createTables(db) @@ -322,4 +344,30 @@ test.group('Session guard | authenticate', () => { assert.equal(authFailed.status, 'fulfilled') assert.equal(authenticateCall.status, 'fulfilled') }) + + test('throw error when calling authenticate after check', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [authFailed, authenticateCall] = await Promise.allSettled([ + pEvent(emitter, 'session_auth:authentication_failed'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.check() + await sessionGuard.authenticate() + }), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authenticateCall.status, 'rejected') + assert.equal( + ('reason' in authenticateCall && authenticateCall.reason).message, + 'Invalid or expired authentication session' + ) + }) }) diff --git a/tests/guards/session/login.spec.ts b/tests/guards/session/login.spec.ts index 95f1b0a..1eb101c 100644 --- a/tests/guards/session/login.spec.ts +++ b/tests/guards/session/login.spec.ts @@ -94,7 +94,7 @@ test.group('Session guard | login', () => { * Ensure the remember me cookie can be decoded by * the server */ - const decodedToken = RememberMeToken.decode(cookies.remember_web.value) + const decodedToken = RememberMeToken.decode(cookies.remember_web.value)! assert.properties(decodedToken, ['series', 'value']) /** diff --git a/tests/guards/session/login_via_id.spec.ts b/tests/guards/session/login_via_id.spec.ts new file mode 100644 index 0000000..b7028d0 --- /dev/null +++ b/tests/guards/session/login_via_id.spec.ts @@ -0,0 +1,69 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' + +test.group('Session guard | loginViaId', () => { + test('login user via id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await Promise.all([ + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.loginViaId(user.id) + }), + ]) + + assert.equal(sessionGuard.user!.id, user.id) + // since the attempt method will fetch from db + assert.notStrictEqual(sessionGuard.user, user) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isFalse(sessionGuard.isAuthenticated) + assert.isFalse(sessionGuard.authenticationAttempted) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + }) + + test('throw error when user for the id does not exists', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [loginFailed, attemptResult] = await Promise.allSettled([ + pEvent(emitter, 'session_auth:login_failed'), + sessionMiddleware.handle(ctx, async () => { + await sessionGuard.loginViaId(1) + }), + ]) + + assert.equal(attemptResult.status, 'rejected') + assert.equal(loginFailed.status, 'fulfilled') + if (attemptResult.status === 'rejected') { + assert.equal(attemptResult.reason.message, 'Invalid credentials') + } + }) +}) From e2f20fc00dd11c058df45bcbee70478a56d9c4d9 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 10:44:34 +0530 Subject: [PATCH 17/96] feat: finish configure command --- configure.ts | 15 ++ package.json | 2 + src/auth/authenticator.ts | 3 +- src/auth/middleware/auth_middleware.ts | 18 ++ .../middleware/initialize_auth_middleware.ts | 37 ++++ stubs/config.stub | 2 +- stubs/config/auth_middleware.stub | 12 - tests/auth/configure.spec.ts | 206 +++++++++++++----- 8 files changed, 229 insertions(+), 66 deletions(-) create mode 100644 src/auth/middleware/auth_middleware.ts create mode 100644 src/auth/middleware/initialize_auth_middleware.ts delete mode 100644 stubs/config/auth_middleware.stub diff --git a/configure.ts b/configure.ts index bbcae4c..ed5e015 100644 --- a/configure.ts +++ b/configure.ts @@ -44,4 +44,19 @@ export async function configure(command: Configure) { await codemods.updateRcFile((rcFile) => { rcFile.addProvider('@adonisjs/auth/auth_provider') }) + + /** + * Register middleware + */ + await codemods.registerMiddleware('router', [ + { + path: '@adonisjs/auth/initialize_auth_middleware', + }, + ]) + await codemods.registerMiddleware('named', [ + { + name: 'auth', + path: '@adonisjs/auth/auth_middleware', + }, + ]) } diff --git a/package.json b/package.json index e792a50..1803ee2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "./core/token_providers/*": "./build/src/core/token_providers/*.js", "./types/core": "./build/src/core/types.js", "./session": "./build/src/guards/session/main.js", + "./initialize_auth_middleware": "./build/src/auth/middleware/initialize_auth_middleware.js", + "./auth_middleware": "./build/src/auth/middleware/auth_middleware.js", "./types/session": "./build/src/guards/session/types.js" }, "scripts": { diff --git a/src/auth/authenticator.ts b/src/auth/authenticator.ts index 5c03236..9384053 100644 --- a/src/auth/authenticator.ts +++ b/src/auth/authenticator.ts @@ -126,7 +126,7 @@ export class Authenticator> { * * Otherwise, "AuthenticationException" will be raised. */ - async authenticateUsing(guards?: (keyof KnownGuards)[]) { + async authenticateUsing(guards?: (keyof KnownGuards)[], options?: { redirectTo?: string }) { const guardsToUse = guards || [this.defaultGuard] let lastUsedGuardDriver: string | undefined @@ -144,6 +144,7 @@ export class Authenticator> { throw new AuthenticationException('Unauthorized access', { code: 'E_UNAUTHORIZED_ACCESS', guardDriverName: lastUsedGuardDriver!, + redirectTo: options?.redirectTo, }) } } diff --git a/src/auth/middleware/auth_middleware.ts b/src/auth/middleware/auth_middleware.ts new file mode 100644 index 0000000..f230d59 --- /dev/null +++ b/src/auth/middleware/auth_middleware.ts @@ -0,0 +1,18 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import type { Authenticators } from '@adonisjs/auth/types' + +/** + * Options accepted by the middleware options + */ +export type AuthMiddlewareOptions = { + guards?: (keyof Authenticators)[] + redirectTo?: string +} + +export default class AuthMiddleware { + async handle(ctx: HttpContext, next: NextFn, options: AuthMiddlewareOptions = {}) { + await ctx.auth.authenticateUsing(options.guards, options) + return next() + } +} diff --git a/src/auth/middleware/initialize_auth_middleware.ts b/src/auth/middleware/initialize_auth_middleware.ts new file mode 100644 index 0000000..0f0d2f1 --- /dev/null +++ b/src/auth/middleware/initialize_auth_middleware.ts @@ -0,0 +1,37 @@ +/// + +import auth from '@adonisjs/auth/services/main' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * The "InitializeAuthMiddleware" is used to create a request + * specific authenticator instance for every HTTP request. + * + * This middleware does not protect routes from unauthenticated + * users. Please use the "auth" middleware for that. + */ +export default class InitializeAuthMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + /** + * Initialize the authenticator for the current HTTP + * request + */ + ctx.auth = auth.createAuthenticator(ctx) + + /** + * Sharing authenticator with templates + */ + if ('view' in ctx) { + ctx.view.share({ auth: ctx.auth }) + } + + return next() + } +} + +declare module '@adonisjs/core/http' { + export interface HttpContext { + auth: ReturnType<(typeof auth)['createAuthenticator']> + } +} diff --git a/stubs/config.stub b/stubs/config.stub index 46d2f68..49be180 100644 --- a/stubs/config.stub +++ b/stubs/config.stub @@ -21,7 +21,7 @@ const userProvider = providers.db({ table: 'users', passwordColumnName: 'password', id: 'id', - uids: ['email'] + uids: ['email'], }) {{/if}} diff --git a/stubs/config/auth_middleware.stub b/stubs/config/auth_middleware.stub deleted file mode 100644 index ad1bb2d..0000000 --- a/stubs/config/auth_middleware.stub +++ /dev/null @@ -1,12 +0,0 @@ -import { HttpContext } from '@adonisjs/core/http' -import { NextFn } from '@adonisjs/core/http/types' - -type AuthMiddlewareOptions = { - guards?: (keyof Authenticators)[] -} - -export default class AuthMiddleware { - async handle({ auth }: HttpContext, next: NextFn, options?: AuthMiddlewareOptions) { - return next() - } -} diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.spec.ts index eaa1f4e..93a82c2 100644 --- a/tests/auth/configure.spec.ts +++ b/tests/auth/configure.spec.ts @@ -7,55 +7,157 @@ * file that was distributed with this source code. */ -// import { test } from '@japa/runner' -// import { fileURLToPath } from 'node:url' -// import { IgnitorFactory } from '@adonisjs/core/factories' -// import Configure from '@adonisjs/core/commands/configure' - -// const BASE_URL = new URL('./tmp/', import.meta.url) - -// test.group('Configure', (group) => { -// group.each.setup(({ context }) => { -// context.fs.baseUrl = BASE_URL -// context.fs.basePath = fileURLToPath(BASE_URL) -// }) - -// test('create config file and register provider', async ({ fs, assert }) => { -// const ignitor = new IgnitorFactory() -// .withCoreProviders() -// .withCoreConfig() -// .create(BASE_URL, { -// importer: (filePath) => { -// if (filePath.startsWith('./') || filePath.startsWith('../')) { -// return import(new URL(filePath, BASE_URL).href) -// } - -// return import(filePath) -// }, -// }) - -// // await fs.create('.env', '') -// // await fs.createJson('tsconfig.json', {}) -// // await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) -// // await fs.create('start/kernel.ts', `router.use([])`) -// // await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) - -// const app = ignitor.createApp('web') -// await app.init() -// await app.boot() - -// const ace = await app.container.make('ace') -// const command = await ace.create(Configure, ['../../../index.js']) -// await command.exec() - -// // await assert.fileExists('config/session.ts') -// // await assert.fileExists('adonisrc.ts') -// // await assert.fileContains('adonisrc.ts', '@adonisjs/session/session_provider') -// // await assert.fileContains('config/session.ts', 'defineConfig') -// // await assert.fileContains('.env', 'SESSION_DRIVER=cookie') -// // await assert.fileContains( -// // 'start/env.ts', -// // `SESSION_DRIVER: Env.schema.enum(['cookie', 'redis', 'file', 'memory'] as const)` -// // ) -// }).timeout(60 * 1000) -// }) +import { test } from '@japa/runner' +import { fileURLToPath } from 'node:url' +import { IgnitorFactory } from '@adonisjs/core/factories' +import Configure from '@adonisjs/core/commands/configure' + +const BASE_URL = new URL('./tmp/', import.meta.url) + +test.group('Configure', (group) => { + group.each.setup(({ context }) => { + context.fs.baseUrl = BASE_URL + context.fs.basePath = fileURLToPath(BASE_URL) + }) + + test('create config file and register provider', async ({ fs, assert }) => { + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .create(BASE_URL, { + importer: (filePath) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, BASE_URL).href) + } + + return import(filePath) + }, + }) + + await fs.create('start/kernel.ts', `router.use([])`) + await fs.createJson('tsconfig.json', {}) + await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + const ace = await app.container.make('ace') + const command = await ace.create(Configure, ['../../../index.js']) + command.prompt.trap('Select the user provider you want to use').replyWith('lucid') + await command.exec() + + await assert.fileExists('config/auth.ts') + await assert.fileExists('adonisrc.ts') + await assert.fileContains('adonisrc.ts', '@adonisjs/auth/auth_provider') + await assert.fileContains( + 'config/auth.ts', + `const userProvider = providers.lucid({ + model: () => import('#models/user'), + uids: ['email'], +})` + ) + await assert.fileContains( + 'config/auth.ts', + `declare module '@adonisjs/auth/types' { + interface Authenticators extends InferAuthenticators {} +}` + ) + }).timeout(60 * 1000) + + test('create config file with db user provider', async ({ fs, assert }) => { + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .create(BASE_URL, { + importer: (filePath) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, BASE_URL).href) + } + + return import(filePath) + }, + }) + + await fs.create('start/kernel.ts', `router.use([])`) + await fs.createJson('tsconfig.json', {}) + await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + const ace = await app.container.make('ace') + const command = await ace.create(Configure, ['../../../index.js']) + command.prompt.trap('Select the user provider you want to use').replyWith('db') + await command.exec() + + await assert.fileExists('config/auth.ts') + await assert.fileExists('adonisrc.ts') + await assert.fileContains('adonisrc.ts', '@adonisjs/auth/auth_provider') + await assert.fileContains( + 'config/auth.ts', + `const userProvider = providers.db({ + table: 'users', + passwordColumnName: 'password', + id: 'id', + uids: ['email'], +})` + ) + await assert.fileContains( + 'config/auth.ts', + `declare module '@adonisjs/auth/types' { + interface Authenticators extends InferAuthenticators {} +}` + ) + }).timeout(60 * 1000) + + test('register middleware', async ({ fs, assert }) => { + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .create(BASE_URL, { + importer: (filePath) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, BASE_URL).href) + } + + return import(filePath) + }, + }) + + await fs.create( + 'start/kernel.ts', + ` + router.use([]) + export const { middleware } = router.named({ + }) + ` + ) + await fs.createJson('tsconfig.json', {}) + await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + const ace = await app.container.make('ace') + const command = await ace.create(Configure, ['../../../index.js']) + command.prompt.trap('Select the user provider you want to use').replyWith('db') + await command.exec() + + await assert.fileExists('config/auth.ts') + await assert.fileExists('adonisrc.ts') + + await assert.fileContains( + 'start/kernel.ts', + `export const { middleware } = router.named({ + auth: () => import('@adonisjs/auth/auth_middleware') +})` + ) + await assert.fileContains( + 'start/kernel.ts', + `router.use([() => import('@adonisjs/auth/initialize_auth_middleware')])` + ) + }).timeout(60 * 1000) +}) From 8e9dfbc3f458ffbc0ce200e13f087aceda1b5fe2 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 10:48:55 +0530 Subject: [PATCH 18/96] fix: typing issues --- tests/core/token_providers/database.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/token_providers/database.spec.ts b/tests/core/token_providers/database.spec.ts index 37733ef..99911f4 100644 --- a/tests/core/token_providers/database.spec.ts +++ b/tests/core/token_providers/database.spec.ts @@ -37,7 +37,7 @@ test.group('Database token provider | createToken', () => { /** * Verifying the token public value matches the saved hash */ - const { value } = TestToken.decode(token.value!) + const { value } = TestToken.decode(token.value!)! assert.isTrue(freshToken.verify(value)) }) @@ -54,7 +54,7 @@ test.group('Database token provider | createToken', () => { /** * Verifying the token public value matches the saved hash */ - const { value } = TestToken.decode(token.value!) + const { value } = TestToken.decode(token.value!)! assert.isTrue(freshToken!.verify(value)) }) From 27b3e76c1182ece3c24dfe6f1609acc5068ac96a Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 10:51:32 +0530 Subject: [PATCH 19/96] chore(release): 9.0.0-1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1803ee2..57caa60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-0", + "version": "9.0.0-1", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From 530a66bcfe7dbc30918c3830db17cb8dc131f1df Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 11:19:39 +0530 Subject: [PATCH 20/96] feat: add option to configure session guard --- configure.ts | 19 ++++++++++++++++++ package.json | 2 +- stubs/guards/session.stub | 21 ++++++++++++++++++++ tests/auth/configure.spec.ts | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 stubs/guards/session.stub diff --git a/configure.ts b/configure.ts index ed5e015..4c22ec0 100644 --- a/configure.ts +++ b/configure.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import { stubsRoot } from './index.js' import type Configure from '@adonisjs/core/commands/configure' /** @@ -31,10 +32,28 @@ async function configureProvider(command: Configure) { await command.publishStub('config.stub', { provider }) } +/** + * Configures the session guard and output its config + * to the console + */ +async function configureSessionGuard(command: Configure) { + const tokens = await command.prompt.confirm('Do you want to use remember me tokens?') + + const stubs = await command.app.stubs.create() + const stub = await stubs.build('guards/session.stub', { source: stubsRoot }) + const { contents } = await stub.prepare({ tokens }) + + command.logger.log(contents) +} + /** * Configures the auth package */ export async function configure(command: Configure) { + if (command.parsedFlags && command.parsedFlags.guard === 'session') { + return configureSessionGuard(command) + } + await configureProvider(command) const codemods = await command.createCodemods() diff --git a/package.json b/package.json index 57caa60..37b1cd6 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ }, "devDependencies": { "@adonisjs/assembler": "^6.1.3-25", - "@adonisjs/core": "^6.1.5-30", + "@adonisjs/core": "^6.1.5-31", "@adonisjs/eslint-config": "^1.1.8", "@adonisjs/i18n": "^2.0.0-6", "@adonisjs/lucid": "^19.0.0-3", diff --git a/stubs/guards/session.stub b/stubs/guards/session.stub new file mode 100644 index 0000000..03ca26b --- /dev/null +++ b/stubs/guards/session.stub @@ -0,0 +1,21 @@ +{{{ + exports({ + to: app.makePath('config/auth.ts') + }) +}}} +{{#if tokens}} +import { sessionGuard, tokensProvider } from '@adonisjs/auth/session' +{{#else}} +import { sessionGuard } from '@adonisjs/auth/session' +{{/if}} + +{ + web: sessionGuard({ + provider: userProvider, + {{#if tokens}} + tokens: tokensProvider.db({ + table: 'remember_me_tokens' + }) + {{/if}} + }) +} diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.spec.ts index 93a82c2..5ba8215 100644 --- a/tests/auth/configure.spec.ts +++ b/tests/auth/configure.spec.ts @@ -160,4 +160,41 @@ test.group('Configure', (group) => { `router.use([() => import('@adonisjs/auth/initialize_auth_middleware')])` ) }).timeout(60 * 1000) + + test('output session guard config', async ({ assert }) => { + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .create(BASE_URL, { + importer: (filePath) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, BASE_URL).href) + } + + return import(filePath) + }, + }) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + const ace = await app.container.make('ace') + ace.ui.switchMode('raw') + + const command = await ace.create(Configure, ['../../../index.js', '--guard=session']) + command.prompt.trap('Do you want to use remember me tokens?').reject() + await command.exec() + + assert.deepEqual(command.logger.getLogs()[0].message.split('\n'), [ + `import { sessionGuard } from '@adonisjs/auth/session'`, + '', + `{`, + ` web: sessionGuard({`, + ` provider: userProvider,`, + ` `, + ` })`, + `}`, + ]) + }).timeout(60 * 1000) }) From 72ef7611ed7e0b24839ffcb1d569bea48f14603f Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 11:26:20 +0530 Subject: [PATCH 21/96] feat: allow configuring login route --- src/auth/auth_manager.ts | 3 ++- src/auth/authenticator.ts | 10 +++++++--- src/auth/define_config.ts | 3 +++ src/auth/middleware/auth_middleware.ts | 2 +- stubs/config.stub | 6 +++++- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/auth/auth_manager.ts b/src/auth/auth_manager.ts index ca0a8b8..97e2c8b 100644 --- a/src/auth/auth_manager.ts +++ b/src/auth/auth_manager.ts @@ -22,6 +22,7 @@ export class AuthManager> { */ #config: { default: keyof KnownGuards + loginRoute: string guards: KnownGuards } @@ -32,7 +33,7 @@ export class AuthManager> { return this.#config.default } - constructor(config: { default: keyof KnownGuards; guards: KnownGuards }) { + constructor(config: { default: keyof KnownGuards; loginRoute: string; guards: KnownGuards }) { this.#config = config } diff --git a/src/auth/authenticator.ts b/src/auth/authenticator.ts index 9384053..43bfff5 100644 --- a/src/auth/authenticator.ts +++ b/src/auth/authenticator.ts @@ -34,6 +34,7 @@ export class Authenticator> { */ #config: { default: keyof KnownGuards + loginRoute: string guards: KnownGuards } @@ -82,7 +83,10 @@ export class Authenticator> { return this.use(this.#authenticatedViaGuard || this.defaultGuard).authenticationAttempted } - constructor(ctx: HttpContext, config: { default: keyof KnownGuards; guards: KnownGuards }) { + constructor( + ctx: HttpContext, + config: { default: keyof KnownGuards; loginRoute: string; guards: KnownGuards } + ) { this.#ctx = ctx this.#config = config debug('creating authenticator. config %O', this.#config) @@ -126,7 +130,7 @@ export class Authenticator> { * * Otherwise, "AuthenticationException" will be raised. */ - async authenticateUsing(guards?: (keyof KnownGuards)[], options?: { redirectTo?: string }) { + async authenticateUsing(guards?: (keyof KnownGuards)[], options?: { loginRoute?: string }) { const guardsToUse = guards || [this.defaultGuard] let lastUsedGuardDriver: string | undefined @@ -144,7 +148,7 @@ export class Authenticator> { throw new AuthenticationException('Unauthorized access', { code: 'E_UNAUTHORIZED_ACCESS', guardDriverName: lastUsedGuardDriver!, - redirectTo: options?.redirectTo, + redirectTo: options?.loginRoute || this.#config.loginRoute, }) } } diff --git a/src/auth/define_config.ts b/src/auth/define_config.ts index 62623a7..1b823a9 100644 --- a/src/auth/define_config.ts +++ b/src/auth/define_config.ts @@ -27,6 +27,7 @@ export type ResolvedAuthConfig< KnownGuards extends Record>, > = { default: keyof KnownGuards + loginRoute: string guards: { [K in keyof KnownGuards]: KnownGuards[K] extends GuardConfigProvider ? A @@ -43,6 +44,7 @@ export function defineConfig< KnownGuards extends Record>, >(config: { default: keyof KnownGuards + loginRoute: string guards: KnownGuards }): ConfigProvider> { return configProvider.create(async (app) => { @@ -60,6 +62,7 @@ export function defineConfig< return { default: config.default, + loginRoute: config.loginRoute, guards: guards, } as ResolvedAuthConfig }) diff --git a/src/auth/middleware/auth_middleware.ts b/src/auth/middleware/auth_middleware.ts index f230d59..8e55f4f 100644 --- a/src/auth/middleware/auth_middleware.ts +++ b/src/auth/middleware/auth_middleware.ts @@ -7,7 +7,7 @@ import type { Authenticators } from '@adonisjs/auth/types' */ export type AuthMiddlewareOptions = { guards?: (keyof Authenticators)[] - redirectTo?: string + loginRoute?: string } export default class AuthMiddleware { diff --git a/stubs/config.stub b/stubs/config.stub index 49be180..a76a398 100644 --- a/stubs/config.stub +++ b/stubs/config.stub @@ -26,7 +26,11 @@ const userProvider = providers.db({ {{/if}} const authConfig = defineConfig({ - guards: {} + default: 'web', + loginRoute: '/login', + guards: { + web: {} // to be configured + } }) export default authConfig From e8b7f825e4b27a94cc451cf9865aaf422c9521b1 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 11:31:43 +0530 Subject: [PATCH 22/96] fix: typing issues --- package.json | 2 +- tests/auth/auth_manager.spec.ts | 1 + tests/auth/authenticator.spec.ts | 4 ++++ tests/auth/define_config.spec.ts | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 37b1cd6..5b4ae19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-1", + "version": "9.0.0-2", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", diff --git a/tests/auth/auth_manager.spec.ts b/tests/auth/auth_manager.spec.ts index c2d01b1..0594903 100644 --- a/tests/auth/auth_manager.spec.ts +++ b/tests/auth/auth_manager.spec.ts @@ -23,6 +23,7 @@ test.group('Auth manager', () => { const authManager = new AuthManager({ default: 'web', + loginRoute: '/login', guards: { web: () => sessionGuard, }, diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index e7294d6..67e3e86 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -24,6 +24,7 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', + loginRoute: '/login', guards: { web: () => sessionGuard, }, @@ -40,6 +41,7 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', + loginRoute: '/login', guards: { web: () => sessionGuard, }, @@ -65,6 +67,7 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', + loginRoute: '/login', guards: { web: () => sessionGuard, }, @@ -96,6 +99,7 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', + loginRoute: '/login', guards: { web: () => sessionGuard, }, diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts index b94402f..fa6ea27 100644 --- a/tests/auth/define_config.spec.ts +++ b/tests/auth/define_config.spec.ts @@ -69,6 +69,7 @@ test.group('Define config', () => { const authConfigProvider = defineConfig({ default: 'web', + loginRoute: '/login', guards: { web: sessionGuard({ provider: lucidConfigProvider, @@ -95,6 +96,7 @@ test.group('Define config', () => { const authConfigProvider = defineConfig({ default: 'web', + loginRoute: '/login', guards: { web: sessionGuard({ provider: lucidConfigProvider, From df2c8a5f9ff962e96994de809fe138af953dfcf0 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 11:56:11 +0530 Subject: [PATCH 23/96] chore(release): 9.0.0-3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b4ae19..05b3911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-2", + "version": "9.0.0-3", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From 0cab536e4b1546eafca540151edd126bded88eb3 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 12:42:48 +0530 Subject: [PATCH 24/96] refactor: remove session guard configuure instructions Instead use docs to tell how to configure the guard --- configure.ts | 19 ------------------ stubs/guards/session.stub | 21 -------------------- tests/auth/configure.spec.ts | 37 ------------------------------------ 3 files changed, 77 deletions(-) delete mode 100644 stubs/guards/session.stub diff --git a/configure.ts b/configure.ts index 4c22ec0..ed5e015 100644 --- a/configure.ts +++ b/configure.ts @@ -7,7 +7,6 @@ * file that was distributed with this source code. */ -import { stubsRoot } from './index.js' import type Configure from '@adonisjs/core/commands/configure' /** @@ -32,28 +31,10 @@ async function configureProvider(command: Configure) { await command.publishStub('config.stub', { provider }) } -/** - * Configures the session guard and output its config - * to the console - */ -async function configureSessionGuard(command: Configure) { - const tokens = await command.prompt.confirm('Do you want to use remember me tokens?') - - const stubs = await command.app.stubs.create() - const stub = await stubs.build('guards/session.stub', { source: stubsRoot }) - const { contents } = await stub.prepare({ tokens }) - - command.logger.log(contents) -} - /** * Configures the auth package */ export async function configure(command: Configure) { - if (command.parsedFlags && command.parsedFlags.guard === 'session') { - return configureSessionGuard(command) - } - await configureProvider(command) const codemods = await command.createCodemods() diff --git a/stubs/guards/session.stub b/stubs/guards/session.stub deleted file mode 100644 index 03ca26b..0000000 --- a/stubs/guards/session.stub +++ /dev/null @@ -1,21 +0,0 @@ -{{{ - exports({ - to: app.makePath('config/auth.ts') - }) -}}} -{{#if tokens}} -import { sessionGuard, tokensProvider } from '@adonisjs/auth/session' -{{#else}} -import { sessionGuard } from '@adonisjs/auth/session' -{{/if}} - -{ - web: sessionGuard({ - provider: userProvider, - {{#if tokens}} - tokens: tokensProvider.db({ - table: 'remember_me_tokens' - }) - {{/if}} - }) -} diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.spec.ts index 5ba8215..93a82c2 100644 --- a/tests/auth/configure.spec.ts +++ b/tests/auth/configure.spec.ts @@ -160,41 +160,4 @@ test.group('Configure', (group) => { `router.use([() => import('@adonisjs/auth/initialize_auth_middleware')])` ) }).timeout(60 * 1000) - - test('output session guard config', async ({ assert }) => { - const ignitor = new IgnitorFactory() - .withCoreProviders() - .withCoreConfig() - .create(BASE_URL, { - importer: (filePath) => { - if (filePath.startsWith('./') || filePath.startsWith('../')) { - return import(new URL(filePath, BASE_URL).href) - } - - return import(filePath) - }, - }) - - const app = ignitor.createApp('web') - await app.init() - await app.boot() - - const ace = await app.container.make('ace') - ace.ui.switchMode('raw') - - const command = await ace.create(Configure, ['../../../index.js', '--guard=session']) - command.prompt.trap('Do you want to use remember me tokens?').reject() - await command.exec() - - assert.deepEqual(command.logger.getLogs()[0].message.split('\n'), [ - `import { sessionGuard } from '@adonisjs/auth/session'`, - '', - `{`, - ` web: sessionGuard({`, - ` provider: userProvider,`, - ` `, - ` })`, - `}`, - ]) - }).timeout(60 * 1000) }) From 4c1fd7132ad6e1fa4fdaa8ad7bbe15aac25d38be Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 13:03:30 +0530 Subject: [PATCH 25/96] refactor: move auth and guest middleware to stubs and publish them --- configure.ts | 16 +++++++++++- package.json | 1 - src/auth/auth_manager.ts | 3 +-- src/auth/authenticator.ts | 8 ++---- src/auth/middleware/auth_middleware.ts | 18 ------------- src/auth/types.ts | 7 +++++ stubs/config.stub | 5 +++- stubs/middleware/auth_middleware.stub | 30 +++++++++++++++++++++ stubs/middleware/guest_middleware.stub | 36 ++++++++++++++++++++++++++ tests/auth/auth_manager.spec.ts | 1 - tests/auth/authenticator.spec.ts | 4 --- tests/auth/configure.spec.ts | 5 +++- 12 files changed, 99 insertions(+), 35 deletions(-) delete mode 100644 src/auth/middleware/auth_middleware.ts create mode 100644 stubs/middleware/auth_middleware.stub create mode 100644 stubs/middleware/guest_middleware.stub diff --git a/configure.ts b/configure.ts index ed5e015..dea9297 100644 --- a/configure.ts +++ b/configure.ts @@ -38,6 +38,16 @@ export async function configure(command: Configure) { await configureProvider(command) const codemods = await command.createCodemods() + /** + * Publish middleware to user application + */ + await command.publishStub('middleware/auth_middleware.stub', { + entity: command.app.generators.createEntity('auth'), + }) + await command.publishStub('middleware/guest_middleware.stub', { + entity: command.app.generators.createEntity('guest'), + }) + /** * Register provider */ @@ -56,7 +66,11 @@ export async function configure(command: Configure) { await codemods.registerMiddleware('named', [ { name: 'auth', - path: '@adonisjs/auth/auth_middleware', + path: '#middleware/auth_middleware', + }, + { + name: 'guest', + path: '#middleware/guest_middleware', }, ]) } diff --git a/package.json b/package.json index 05b3911..b2f12e9 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "./types/core": "./build/src/core/types.js", "./session": "./build/src/guards/session/main.js", "./initialize_auth_middleware": "./build/src/auth/middleware/initialize_auth_middleware.js", - "./auth_middleware": "./build/src/auth/middleware/auth_middleware.js", "./types/session": "./build/src/guards/session/types.js" }, "scripts": { diff --git a/src/auth/auth_manager.ts b/src/auth/auth_manager.ts index 97e2c8b..ca0a8b8 100644 --- a/src/auth/auth_manager.ts +++ b/src/auth/auth_manager.ts @@ -22,7 +22,6 @@ export class AuthManager> { */ #config: { default: keyof KnownGuards - loginRoute: string guards: KnownGuards } @@ -33,7 +32,7 @@ export class AuthManager> { return this.#config.default } - constructor(config: { default: keyof KnownGuards; loginRoute: string; guards: KnownGuards }) { + constructor(config: { default: keyof KnownGuards; guards: KnownGuards }) { this.#config = config } diff --git a/src/auth/authenticator.ts b/src/auth/authenticator.ts index 43bfff5..a85dad6 100644 --- a/src/auth/authenticator.ts +++ b/src/auth/authenticator.ts @@ -34,7 +34,6 @@ export class Authenticator> { */ #config: { default: keyof KnownGuards - loginRoute: string guards: KnownGuards } @@ -83,10 +82,7 @@ export class Authenticator> { return this.use(this.#authenticatedViaGuard || this.defaultGuard).authenticationAttempted } - constructor( - ctx: HttpContext, - config: { default: keyof KnownGuards; loginRoute: string; guards: KnownGuards } - ) { + constructor(ctx: HttpContext, config: { default: keyof KnownGuards; guards: KnownGuards }) { this.#ctx = ctx this.#config = config debug('creating authenticator. config %O', this.#config) @@ -148,7 +144,7 @@ export class Authenticator> { throw new AuthenticationException('Unauthorized access', { code: 'E_UNAUTHORIZED_ACCESS', guardDriverName: lastUsedGuardDriver!, - redirectTo: options?.loginRoute || this.#config.loginRoute, + redirectTo: options?.loginRoute, }) } } diff --git a/src/auth/middleware/auth_middleware.ts b/src/auth/middleware/auth_middleware.ts deleted file mode 100644 index 8e55f4f..0000000 --- a/src/auth/middleware/auth_middleware.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { HttpContext } from '@adonisjs/core/http' -import type { NextFn } from '@adonisjs/core/types/http' -import type { Authenticators } from '@adonisjs/auth/types' - -/** - * Options accepted by the middleware options - */ -export type AuthMiddlewareOptions = { - guards?: (keyof Authenticators)[] - loginRoute?: string -} - -export default class AuthMiddleware { - async handle(ctx: HttpContext, next: NextFn, options: AuthMiddlewareOptions = {}) { - await ctx.auth.authenticateUsing(options.guards, options) - return next() - } -} diff --git a/src/auth/types.ts b/src/auth/types.ts index 79cb15b..ea28a13 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -82,6 +82,13 @@ export type InferAuthenticators< }>, > = Awaited>['guards'] +/** + * Infer events based upon the configure authenticators + */ +export type InferAuthEvents> = { + [K in keyof KnownAuthenticators]: ReturnType[typeof GUARD_KNOWN_EVENTS] +}[keyof KnownAuthenticators] + /** * Auth service is a singleton instance of the AuthManager * configured using the config stored within the user diff --git a/stubs/config.stub b/stubs/config.stub index a76a398..d107b13 100644 --- a/stubs/config.stub +++ b/stubs/config.stub @@ -2,6 +2,7 @@ exports({ to: app.configPath('auth.ts') }) }}} import { defineConfig, providers } from '@adonisjs/auth' +import { InferAuthEvents, Authenticators } from '@adonisjs/auth/types' {{#if provider === 'lucid'}} /** * Using the "models/user" model to find users during @@ -27,7 +28,6 @@ const userProvider = providers.db({ const authConfig = defineConfig({ default: 'web', - loginRoute: '/login', guards: { web: {} // to be configured } @@ -37,3 +37,6 @@ export default authConfig declare module '@adonisjs/auth/types' { interface Authenticators extends InferAuthenticators {} } +declare module '@adonisjs/core/types' { + interface EventsList extends InferAuthEvents {} +} diff --git a/stubs/middleware/auth_middleware.stub b/stubs/middleware/auth_middleware.stub new file mode 100644 index 0000000..1d4976a --- /dev/null +++ b/stubs/middleware/auth_middleware.stub @@ -0,0 +1,30 @@ +{{#var middlewareName = generators.middlewareName(entity.name)}} +{{#var middlewareFileName = generators.middlewareFileName(entity.name)}} +{{{ + exports({ to: app.middlewarePath(entity.path, middlewareFileName) }) +}}} +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import type { Authenticators } from '@adonisjs/auth/types' + +/** + * Auth middleware is used authenticate HTTP requests and deny + * access to unauthenticated users. + */ +export default class {{ middlewareName }} { + /** + * The URL to redirect to, when authentication fails + */ + redirectTo = '/login' + + async handle( + ctx: HttpContext, + next: NextFn, + options: { + guards?: (keyof Authenticators)[] + } = {} + ) { + await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo }) + return next() + } +} diff --git a/stubs/middleware/guest_middleware.stub b/stubs/middleware/guest_middleware.stub new file mode 100644 index 0000000..ea6d7dc --- /dev/null +++ b/stubs/middleware/guest_middleware.stub @@ -0,0 +1,36 @@ +{{#var middlewareName = generators.middlewareName(entity.name)}} +{{#var middlewareFileName = generators.middlewareFileName(entity.name)}} +{{{ + exports({ to: app.middlewarePath(entity.path, middlewareFileName) }) +}}} +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import type { Authenticators } from '@adonisjs/auth/types' + +/** + * Guest middleware is used to deny access to routes that should + * be accessed by unauthenticated users. + * + * For example, the login page should not be accessible if the user + * is already logged-in + */ +export default class {{ middlewareName }} { + /** + * The URL to redirect to when user is logged-in + */ + redirectTo = '/' + + async handle( + ctx: HttpContext, + next: NextFn, + options: { guards?: (keyof Authenticators)[] } = {} + ) { + for (let guard of options.guards || [ctx.auth.defaultGuard]) { + if (await ctx.auth.use(guard).check()) { + return ctx.response.redirect(this.redirectTo, true) + } + } + + return next() + } +} diff --git a/tests/auth/auth_manager.spec.ts b/tests/auth/auth_manager.spec.ts index 0594903..c2d01b1 100644 --- a/tests/auth/auth_manager.spec.ts +++ b/tests/auth/auth_manager.spec.ts @@ -23,7 +23,6 @@ test.group('Auth manager', () => { const authManager = new AuthManager({ default: 'web', - loginRoute: '/login', guards: { web: () => sessionGuard, }, diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index 67e3e86..e7294d6 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -24,7 +24,6 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', - loginRoute: '/login', guards: { web: () => sessionGuard, }, @@ -41,7 +40,6 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', - loginRoute: '/login', guards: { web: () => sessionGuard, }, @@ -67,7 +65,6 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', - loginRoute: '/login', guards: { web: () => sessionGuard, }, @@ -99,7 +96,6 @@ test.group('Authenticator', () => { const authenticator = new Authenticator(ctx, { default: 'web', - loginRoute: '/login', guards: { web: () => sessionGuard, }, diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.spec.ts index 93a82c2..9aa1bf0 100644 --- a/tests/auth/configure.spec.ts +++ b/tests/auth/configure.spec.ts @@ -148,11 +148,14 @@ test.group('Configure', (group) => { await assert.fileExists('config/auth.ts') await assert.fileExists('adonisrc.ts') + await assert.fileExists('app/middleware/auth_middleware.ts') + await assert.fileExists('app/middleware/guest_middleware.ts') await assert.fileContains( 'start/kernel.ts', `export const { middleware } = router.named({ - auth: () => import('@adonisjs/auth/auth_middleware') + guest: () => import('#middleware/guest_middleware'), + auth: () => import('#middleware/auth_middleware') })` ) await assert.fileContains( From 59382584114beea9754beb7710ed708a160f0cda Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 14:20:18 +0530 Subject: [PATCH 26/96] feat: implement session.logout --- src/guards/session/guard.ts | 63 ++++++++++- src/guards/session/types.ts | 10 +- tests/guards/session/logout.spec.ts | 165 ++++++++++++++++++++++++++++ tests/helpers.ts | 13 ++- 4 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 tests/guards/session/logout.spec.ts diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts index 91d4d59..491bc5a 100644 --- a/src/guards/session/guard.ts +++ b/src/guards/session/guard.ts @@ -171,6 +171,7 @@ export class SessionGuard { + async attempt( + uid: string, + password: string, + remember?: boolean + ): Promise { const user = await this.verifyCredentials(uid, password) - return this.login(user) + return this.login(user, remember) } /** @@ -285,7 +292,10 @@ export class SessionGuard { + async loginViaId( + id: string | number, + remember?: boolean + ): Promise { debug('session_guard: attempting to login user via id "%s"', id) const providerUser = await this.#userProvider.findById(id) @@ -293,7 +303,7 @@ export class SessionGuard { if (this.#emitter) { - this.#emitter.emit('session_auth:login_attempted', { user }) + this.#emitter.emit('session_auth:login_attempted', { user, guardName: this.#name }) } const providerUser = await this.#userProvider.createUserForGuard(user) @@ -362,6 +372,7 @@ export class SessionGuard = { * have been verified successfully. */ 'session_auth:credentials_verified': { + guardName: string uid: string user: User } @@ -57,6 +58,7 @@ export type SessionGuardEvents = { * user. */ 'session_auth:login_failed': { + guardName: string error: Exception user: User | null } @@ -66,6 +68,7 @@ export type SessionGuardEvents = { * a given user. */ 'session_auth:login_attempted': { + guardName: string user: User } @@ -74,6 +77,7 @@ export type SessionGuardEvents = { * successfully */ 'session_auth:login_succeeded': { + guardName: string user: User sessionId: string rememberMeToken?: RememberMeToken @@ -83,6 +87,7 @@ export type SessionGuardEvents = { * Attempting to authenticate the user */ 'session_auth:authentication_attempted': { + guardName: string sessionId: string } @@ -90,6 +95,7 @@ export type SessionGuardEvents = { * Authentication was successful */ 'session_auth:authentication_succeeded': { + guardName: string user: User sessionId: string rememberMeToken?: RememberMeToken @@ -99,6 +105,7 @@ export type SessionGuardEvents = { * Authentication failed */ 'session_auth:authentication_failed': { + guardName: string error: Exception sessionId: string } @@ -108,7 +115,8 @@ export type SessionGuardEvents = { * sucessfully */ 'session_auth:logged_out': { - user: User + guardName: string + user: User | null sessionId: string } } diff --git a/tests/guards/session/logout.spec.ts b/tests/guards/session/logout.spec.ts new file mode 100644 index 0000000..21e9836 --- /dev/null +++ b/tests/guards/session/logout.spec.ts @@ -0,0 +1,165 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Socket } from 'node:net' +import { test } from '@japa/runner' +import { IncomingMessage } from 'node:http' +import { CookieClient } from '@adonisjs/core/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' +import { HttpContextFactory, RequestFactory } from '@adonisjs/core/factories/http' + +import { RememberMeToken } from '../../../src/guards/session/token.js' +import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { + pEvent, + encryption, + createTables, + parseCookies, + createEmitter, + createDatabase, +} from '../../helpers.js' +import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/main.js' + +test.group('Session guard | logout', () => { + test('logout user by deleting auth data from session store', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.login(user) + }) + + assert.strictEqual(sessionGuard.user, user) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isFalse(sessionGuard.isAuthenticated) + assert.isFalse(sessionGuard.authenticationAttempted) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + + /** + * Logging out + */ + await sessionGuard.logout() + assert.deepEqual(ctx.session.all(), {}) + }) + + test('logout user by deleting remember me token and cookie', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const user = await FactoryUser.createWithDefaults() + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const token = RememberMeToken.create(user.id, '1 year') + await tokensProvider.createToken(token) + + const client = new CookieClient(encryption) + const req = new IncomingMessage(new Socket()) + req.headers['cookie'] = `remember_web=${client.encrypt('remember_web', token.value)};` + + const ctx = new HttpContextFactory() + .merge({ + request: new RequestFactory() + .merge({ + req, + }) + .create(), + }) + .create() + + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.logout() + }) + + assert.deepEqual(ctx.session.all(), {}) + + const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) + assert.property(cookies, 'remember_web') + assert.equal(cookies.remember_web.maxAge, -1) + assert.equal(cookies.remember_web.httpOnly, true) + + const persistedToken = await tokensProvider.getTokenBySeries(token.series) + assert.isNull(persistedToken) + }) + + test('emit logged_out event', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.login(user) + }) + + assert.strictEqual(sessionGuard.user, user) + assert.isFalse(sessionGuard.isLoggedOut) + assert.isFalse(sessionGuard.isAuthenticated) + assert.isFalse(sessionGuard.authenticationAttempted) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + + /** + * Logging out + */ + + const [loggedOut] = await Promise.all([ + pEvent(emitter, 'session_auth:logged_out'), + sessionGuard.logout(), + ]) + + assert.deepEqual(loggedOut!.user, sessionGuard.user) + assert.equal(loggedOut!.sessionId, ctx.session.sessionId) + assert.deepEqual(ctx.session.all(), {}) + }) + + test('silently ignore invalid remember me token', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const client = new CookieClient(encryption) + const req = new IncomingMessage(new Socket()) + req.headers['cookie'] = `remember_web=${client.encrypt('remember_web', 'foo')};` + + const ctx = new HttpContextFactory() + .merge({ + request: new RequestFactory() + .merge({ + req, + }) + .create(), + }) + .create() + + const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.logout() + }) + + assert.deepEqual(ctx.session.all(), {}) + + const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) + assert.property(cookies, 'remember_web') + assert.equal(cookies.remember_web.maxAge, -1) + assert.equal(cookies.remember_web.httpOnly, true) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 98a42ea..142f8ce 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -16,6 +16,7 @@ import { Emitter } from '@adonisjs/core/events' import { BaseModel } from '@adonisjs/lucid/orm' import { CookieClient } from '@adonisjs/core/http' import { Database } from '@adonisjs/lucid/database' +import { Encryption } from '@adonisjs/core/encryption' import { Scrypt } from '@adonisjs/hash/drivers/scrypt' import { AppFactory } from '@adonisjs/core/factories/app' import setCookieParser, { CookieMap } from 'set-cookie-parser' @@ -25,6 +26,8 @@ import { EncryptionFactory } from '@adonisjs/core/factories/encryption' import { SessionGuardEvents } from '../src/guards/session/types.js' import { FactoryUser } from '../factories/lucid_user_provider.js' +export const encryption: Encryption = new EncryptionFactory().create() + /** * Creates a fresh instance of AdonisJS hash module * with scrypt driver @@ -141,12 +144,14 @@ export function pEvent, K extend */ export function parseCookies(setCookiesHeader: string | string[]) { const cookies = setCookieParser(setCookiesHeader, { map: true }) - const client = new CookieClient(new EncryptionFactory().create()) + const client = new CookieClient(encryption) return Object.keys(cookies).reduce((result, key) => { + const cookie = cookies[key] + result[key] = { - ...cookies[key], - value: client.parse(cookies[key].name, cookies[key].value), + ...cookie, + value: cookie.value ? client.parse(cookie.name, cookie.value) : cookie.value, } return result }, {} as CookieMap) @@ -162,7 +167,7 @@ export function defineCookies( type: 'plain' | 'encrypted' | 'signed' }[] ) { - const client = new CookieClient(new EncryptionFactory().create()) + const client = new CookieClient(encryption) return cookies .reduce((result, cookie) => { From 3a3b0ed757dccb7b6ce442da77d194d3ec13591c Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 14:25:03 +0530 Subject: [PATCH 27/96] feat: add getUserOrFail method to authenticator --- src/auth/authenticator.ts | 12 ++++++++++++ src/auth/types.ts | 5 +++++ tests/auth/authenticator.spec.ts | 1 + 3 files changed, 18 insertions(+) diff --git a/src/auth/authenticator.ts b/src/auth/authenticator.ts index a85dad6..e892a15 100644 --- a/src/auth/authenticator.ts +++ b/src/auth/authenticator.ts @@ -88,6 +88,18 @@ export class Authenticator> { debug('creating authenticator. config %O', this.#config) } + /** + * Returns an instance of the logged-in user or throws an + * exception + */ + getUserOrFail(): { + [K in keyof KnownGuards]: ReturnType['getUserOrFail']> + }[keyof KnownGuards] { + return this.use(this.#authenticatedViaGuard || this.defaultGuard).getUserOrFail() as { + [K in keyof KnownGuards]: ReturnType['getUserOrFail']> + }[keyof KnownGuards] + } + /** * Returns an instance of a known guard. Guards instances are * cached during the lifecycle of an HTTP request. diff --git a/src/auth/types.ts b/src/auth/types.ts index ea28a13..de1eb07 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -22,6 +22,11 @@ export interface GuardContract { */ user?: User + /** + * Returns logged-in user or throws an exception + */ + getUserOrFail(): User + /** * A boolean to know if the current request has * been authenticated diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index e7294d6..fa0b0ec 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -78,6 +78,7 @@ test.group('Authenticator', () => { assert.instanceOf(authenticator.user, FactoryUser) assert.equal(authenticator.user!.id, user.id) expectTypeOf(authenticator.user).toMatchTypeOf() + expectTypeOf(authenticator.getUserOrFail()).toMatchTypeOf() assert.equal(authenticator.authenticatedViaGuard, 'web') assert.isTrue(authenticator.isAuthenticated) assert.isTrue(authenticator.authenticationAttempted) From 53ce132573c5f12bc8776d253c2a73e9ffd077d2 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 14:32:11 +0530 Subject: [PATCH 28/96] chore(release): 9.0.0-4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b2f12e9..60656a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-3", + "version": "9.0.0-4", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From ab0735ddab6f008e6d3578f56ecbc5f2a6d9b0ee Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 25 Oct 2023 15:24:29 +0530 Subject: [PATCH 29/96] refactor: simplify initial setup We do not create any config file by default. Ask the user to go through the docs and setup config file as per the guards they want to use --- configure.ts | 23 ------------- stubs/config.stub | 42 ----------------------- tests/auth/configure.spec.ts | 66 +----------------------------------- 3 files changed, 1 insertion(+), 130 deletions(-) delete mode 100644 stubs/config.stub diff --git a/configure.ts b/configure.ts index dea9297..888bd51 100644 --- a/configure.ts +++ b/configure.ts @@ -9,33 +9,10 @@ import type Configure from '@adonisjs/core/commands/configure' -/** - * Configures the user provider to use for finding - * users - */ -async function configureProvider(command: Configure) { - const provider = await command.prompt.choice('Select the user provider you want to use', [ - { - name: 'lucid', - message: 'Lucid models', - }, - { - name: 'db', - message: 'Database query builder', - }, - ]) - - /** - * Publish config file - */ - await command.publishStub('config.stub', { provider }) -} - /** * Configures the auth package */ export async function configure(command: Configure) { - await configureProvider(command) const codemods = await command.createCodemods() /** diff --git a/stubs/config.stub b/stubs/config.stub deleted file mode 100644 index d107b13..0000000 --- a/stubs/config.stub +++ /dev/null @@ -1,42 +0,0 @@ -{{{ - exports({ to: app.configPath('auth.ts') }) -}}} -import { defineConfig, providers } from '@adonisjs/auth' -import { InferAuthEvents, Authenticators } from '@adonisjs/auth/types' -{{#if provider === 'lucid'}} -/** - * Using the "models/user" model to find users during - * login and authentication - */ -const userProvider = providers.lucid({ - model: () => import('#models/user'), - uids: ['email'], -}) -{{/if}} -{{#if provider === 'db'}} -/** - * Using Lucid query builder to directly query the database - * to find users during login and authentication. - */ -const userProvider = providers.db({ - table: 'users', - passwordColumnName: 'password', - id: 'id', - uids: ['email'], -}) -{{/if}} - -const authConfig = defineConfig({ - default: 'web', - guards: { - web: {} // to be configured - } -}) - -export default authConfig -declare module '@adonisjs/auth/types' { - interface Authenticators extends InferAuthenticators {} -} -declare module '@adonisjs/core/types' { - interface EventsList extends InferAuthEvents {} -} diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.spec.ts index 9aa1bf0..f6a303d 100644 --- a/tests/auth/configure.spec.ts +++ b/tests/auth/configure.spec.ts @@ -20,7 +20,7 @@ test.group('Configure', (group) => { context.fs.basePath = fileURLToPath(BASE_URL) }) - test('create config file and register provider', async ({ fs, assert }) => { + test('register provider', async ({ fs, assert }) => { const ignitor = new IgnitorFactory() .withCoreProviders() .withCoreConfig() @@ -44,72 +44,10 @@ test.group('Configure', (group) => { const ace = await app.container.make('ace') const command = await ace.create(Configure, ['../../../index.js']) - command.prompt.trap('Select the user provider you want to use').replyWith('lucid') await command.exec() - await assert.fileExists('config/auth.ts') await assert.fileExists('adonisrc.ts') await assert.fileContains('adonisrc.ts', '@adonisjs/auth/auth_provider') - await assert.fileContains( - 'config/auth.ts', - `const userProvider = providers.lucid({ - model: () => import('#models/user'), - uids: ['email'], -})` - ) - await assert.fileContains( - 'config/auth.ts', - `declare module '@adonisjs/auth/types' { - interface Authenticators extends InferAuthenticators {} -}` - ) - }).timeout(60 * 1000) - - test('create config file with db user provider', async ({ fs, assert }) => { - const ignitor = new IgnitorFactory() - .withCoreProviders() - .withCoreConfig() - .create(BASE_URL, { - importer: (filePath) => { - if (filePath.startsWith('./') || filePath.startsWith('../')) { - return import(new URL(filePath, BASE_URL).href) - } - - return import(filePath) - }, - }) - - await fs.create('start/kernel.ts', `router.use([])`) - await fs.createJson('tsconfig.json', {}) - await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) - - const app = ignitor.createApp('web') - await app.init() - await app.boot() - - const ace = await app.container.make('ace') - const command = await ace.create(Configure, ['../../../index.js']) - command.prompt.trap('Select the user provider you want to use').replyWith('db') - await command.exec() - - await assert.fileExists('config/auth.ts') - await assert.fileExists('adonisrc.ts') - await assert.fileContains('adonisrc.ts', '@adonisjs/auth/auth_provider') - await assert.fileContains( - 'config/auth.ts', - `const userProvider = providers.db({ - table: 'users', - passwordColumnName: 'password', - id: 'id', - uids: ['email'], -})` - ) - await assert.fileContains( - 'config/auth.ts', - `declare module '@adonisjs/auth/types' { - interface Authenticators extends InferAuthenticators {} -}` - ) }).timeout(60 * 1000) test('register middleware', async ({ fs, assert }) => { @@ -143,10 +81,8 @@ test.group('Configure', (group) => { const ace = await app.container.make('ace') const command = await ace.create(Configure, ['../../../index.js']) - command.prompt.trap('Select the user provider you want to use').replyWith('db') await command.exec() - await assert.fileExists('config/auth.ts') await assert.fileExists('adonisrc.ts') await assert.fileExists('app/middleware/auth_middleware.ts') await assert.fileExists('app/middleware/guest_middleware.ts') From f5809fdca9376089bca10cb5bf6b0c41aa6e9df5 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 26 Oct 2023 15:27:07 +0530 Subject: [PATCH 30/96] refactor: small fixes --- src/auth/errors.ts | 4 ++++ src/auth/types.ts | 18 +++++++++++++++--- src/core/token_providers/database.ts | 2 +- src/guards/session/types.ts | 2 +- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/auth/errors.ts b/src/auth/errors.ts index b28d6c3..2df4268 100644 --- a/src/auth/errors.ts +++ b/src/auth/errors.ts @@ -16,6 +16,9 @@ import { HttpContext } from '@adonisjs/core/http' * made to authenticate an HTTP request */ export class AuthenticationException extends Exception { + static status?: number | undefined = 401 + static code?: string | undefined = 'E_UNAUTHORIZED_ACCESS' + /** * Raises authentication exception when session guard * is unable to authenticate the request @@ -126,6 +129,7 @@ export class AuthenticationException extends Exception { export class InvalidCredentialsException extends Exception { static message: string = 'Invalid credentials' static code: string = 'E_INVALID_CREDENTIALS' + static status?: number | undefined = 400 static E_INVALID_CREDENTIALS(guardDriverName: string) { return new InvalidCredentialsException(InvalidCredentialsException.message, { diff --git a/src/auth/types.ts b/src/auth/types.ts index de1eb07..8be6fc9 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -87,12 +87,24 @@ export type InferAuthenticators< }>, > = Awaited>['guards'] +/** + * Helper to convert union to intersection + */ +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void + ? I + : never + /** * Infer events based upon the configure authenticators */ -export type InferAuthEvents> = { - [K in keyof KnownAuthenticators]: ReturnType[typeof GUARD_KNOWN_EVENTS] -}[keyof KnownAuthenticators] +export type InferAuthEvents> = + UnionToIntersection< + { + [K in keyof KnownAuthenticators]: ReturnType< + KnownAuthenticators[K] + >[typeof GUARD_KNOWN_EVENTS] + }[keyof KnownAuthenticators] + > /** * Auth service is a singleton instance of the AuthManager diff --git a/src/core/token_providers/database.ts b/src/core/token_providers/database.ts index 51fbbaa..7d2f5d2 100644 --- a/src/core/token_providers/database.ts +++ b/src/core/token_providers/database.ts @@ -23,7 +23,7 @@ type DatabaseTokenRow = { created_at: Date updated_at: Date expires_at: Date | null -} +} & Record /** * A generic implementation to read tokens from the database diff --git a/src/guards/session/types.ts b/src/guards/session/types.ts index 56dd85d..cee8ef4 100644 --- a/src/guards/session/types.ts +++ b/src/guards/session/types.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import { Exception } from '@poppinss/utils' +import type { Exception } from '@poppinss/utils' import type { RememberMeToken } from './token.js' import type { From 421780ba01318a0799c13637ae64c14c6e7ffd7e Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 26 Oct 2023 16:13:16 +0530 Subject: [PATCH 31/96] refactor: export exception classes from main entrypoint --- index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 8e86abd..c8b9adc 100644 --- a/index.ts +++ b/index.ts @@ -9,8 +9,8 @@ export { configure } from './configure.js' export { stubsRoot } from './stubs/main.js' -export * as errors from './src/auth/errors.js' export * as symbols from './src/auth/symbols.js' export { AuthManager } from './src/auth/auth_manager.js' export { Authenticator } from './src/auth/authenticator.js' export { defineConfig, providers } from './src/auth/define_config.js' +export { AuthenticationException, InvalidCredentialsException } from './src/auth/errors.js' From d43d89b3f858865cb0b5a2de59748c4846736446 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 26 Oct 2023 21:30:54 +0530 Subject: [PATCH 32/96] chore(release): 9.0.0-5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 60656a9..279e32a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-4", + "version": "9.0.0-5", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From 2fb7b72b70c7fe2227f0398b9419dde0b8ebcdbd Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 27 Oct 2023 11:21:09 +0530 Subject: [PATCH 33/96] feat: add api client integration --- package.json | 10 ++- src/auth/auth_manager.ts | 8 +++ src/auth/authenticator_client.ts | 73 +++++++++++++++++++ src/auth/plugins/japa/api_client.ts | 108 ++++++++++++++++++++++++++++ src/auth/types.ts | 16 +++++ src/guards/session/guard.ts | 20 +++++- 6 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 src/auth/authenticator_client.ts create mode 100644 src/auth/plugins/japa/api_client.ts diff --git a/package.json b/package.json index 279e32a..872e9ba 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ ".": "./build/index.js", "./types": "./build/src/auth/types.js", "./auth_provider": "./build/providers/auth_provider.js", + "./plugins/api_client": "./build/src/auth/plugins/japa/api_client.js", "./services/main": "./build/services/auth.js", "./core/token": "./build/src/core/token.js", "./core/guard_user": "./build/src/core/guard_user.js", @@ -74,6 +75,7 @@ "@adonisjs/tsconfig": "^1.1.8", "@commitlint/cli": "^18.0.0", "@commitlint/config-conventional": "^18.0.0", + "@japa/api-client": "^2.0.0", "@japa/assert": "^2.0.0", "@japa/expect-type": "^2.0.0", "@japa/file-system": "^2.0.0", @@ -132,9 +134,10 @@ "@poppinss/utils": "^6.5.0" }, "peerDependencies": { - "@adonisjs/core": "^6.1.5-30", + "@adonisjs/core": "^6.1.5-31", "@adonisjs/lucid": "^19.0.0-3", - "@adonisjs/session": "^7.0.0-13" + "@adonisjs/session": "^7.0.0-13", + "@japa/api-client": "^2.0.0" }, "peerDependenciesMeta": { "@adonisjs/lucid": { @@ -142,6 +145,9 @@ }, "@adonisjs/session": { "optional": true + }, + "@japa/api-client": { + "optional": true } } } diff --git a/src/auth/auth_manager.ts b/src/auth/auth_manager.ts index ca0a8b8..7b26d83 100644 --- a/src/auth/auth_manager.ts +++ b/src/auth/auth_manager.ts @@ -11,6 +11,7 @@ import type { HttpContext } from '@adonisjs/core/http' import type { GuardFactory } from './types.js' import { Authenticator } from './authenticator.js' +import { AuthenticatorClient } from './authenticator_client.js' /** * Auth manager exposes the API to register and manage authentication @@ -42,4 +43,11 @@ export class AuthManager> { createAuthenticator(ctx: HttpContext) { return new Authenticator(ctx, this.#config) } + + /** + * Creates an instance of the authenticator client + */ + createAuthenticatorClient() { + return new AuthenticatorClient(this.#config) + } } diff --git a/src/auth/authenticator_client.ts b/src/auth/authenticator_client.ts new file mode 100644 index 0000000..e4523cc --- /dev/null +++ b/src/auth/authenticator_client.ts @@ -0,0 +1,73 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import debug from './debug.js' +import type { GuardFactory } from './types.js' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +/** + * Authenticator client is used to create guard instances for + * testing. It passes a fake HTTPContext to the guards, so + * make sure to not call server side APIs that might be + * relying on a real HTTPContext instance + */ +export class AuthenticatorClient> { + /** + * Registered guards + */ + #config: { + default: keyof KnownGuards + guards: KnownGuards + } + + /** + * Cache of guards + */ + #guardsCache: Partial> = {} + + /** + * Name of the default guard + */ + get defaultGuard(): keyof KnownGuards { + return this.#config.default + } + + constructor(config: { default: keyof KnownGuards; guards: KnownGuards }) { + this.#config = config + debug('creating authenticator client. config %O', this.#config) + } + + /** + * Returns an instance of a known guard. Guards instances are + * cached during the lifecycle of an HTTP request. + */ + use(guard?: Guard): ReturnType { + const guardToUse = guard || this.#config.default + + /** + * Use cached copy if exists + */ + const cachedGuard = this.#guardsCache[guardToUse] + if (cachedGuard) { + debug('using guard from cache. name: "%s"', guardToUse) + return cachedGuard as ReturnType + } + + const guardFactory = this.#config.guards[guardToUse] + + /** + * Construct guard and cache it + */ + debug('creating guard. name: "%s"', guardToUse) + const guardInstance = guardFactory(new HttpContextFactory().create()) + this.#guardsCache[guardToUse] = guardInstance + + return guardInstance as ReturnType + } +} diff --git a/src/auth/plugins/japa/api_client.ts b/src/auth/plugins/japa/api_client.ts new file mode 100644 index 0000000..01791ff --- /dev/null +++ b/src/auth/plugins/japa/api_client.ts @@ -0,0 +1,108 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// + +import { ApiClient, ApiRequest } from '@japa/api-client' +import type { ApplicationService } from '@adonisjs/core/types' +import type { Authenticators, GuardContract, GuardFactory } from '../../types.js' + +declare module '@japa/api-client' { + export interface ApiRequest { + authData: { + guard: string + user: unknown + } + + /** + * Login a user using the default authentication + * guard when making an API call + */ + loginAs(user: { + [K in keyof Authenticators]: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never + : never + }): this + + /** + * Define the authentication guard for login + */ + withGuard( + this: Self, + guard: K + ): { + /** + * Login a user using a specific auth guard + */ + loginAs( + user: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never + : never + ): Self + } + } +} + +/** + * Auth API client to authenticate users when making + * HTTP requests using the Japa API client + */ +export const authApiClient = (app: ApplicationService) => { + ApiRequest.macro('loginAs', function (this: ApiRequest, user) { + this.authData = { + guard: '__default__', + user: user, + } + return this + }) + + ApiRequest.macro('withGuard', function < + K extends keyof Authenticators, + Self extends ApiRequest, + >(this: Self, guard: K) { + return { + loginAs: (user) => { + this.authData = { + guard, + user: user, + } + return this + }, + } + }) + + /** + * Hook into the request and login the user + */ + ApiClient.setup(async (request) => { + const auth = await app.container.make('auth.manager') + const authData = request['authData'] + if (!authData) { + return + } + + const client = auth.createAuthenticatorClient() + const guard = authData.guard === '__default__' ? client.use() : client.use(authData.guard) + const requestData = await (guard as GuardContract).authenticateAsClient(authData.user) + + if (requestData.headers) { + request.headers(requestData.headers) + } + if (requestData.session) { + request.withSession(requestData.session) + } + if (requestData.cookies) { + request.cookies(requestData.cookies) + } + }) +} diff --git a/src/auth/types.ts b/src/auth/types.ts index 8be6fc9..08006be 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -13,6 +13,15 @@ import type { ApplicationService, ConfigProvider } from '@adonisjs/core/types' import type { AuthManager } from './auth_manager.js' import type { GUARD_KNOWN_EVENTS } from './symbols.js' +/** + * The client response for authentication. + */ +export interface AuthClientResponse { + headers?: Record + cookies?: Record + session?: Record +} + /** * A set of properties a guard must implement. */ @@ -46,6 +55,13 @@ export interface GuardContract { */ check(): Promise + /** + * The method is used to authenticate the user as + * client. This method should return cookies, + * headers, or session state. + */ + authenticateAsClient(user: User): Promise + /** * Authenticates the current request and throws * an exception if the request is not authenticated. diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts index 491bc5a..9309f0a 100644 --- a/src/guards/session/guard.ts +++ b/src/guards/session/guard.ts @@ -15,7 +15,7 @@ import { Exception, RuntimeException } from '@poppinss/utils' import debug from '../../auth/debug.js' import { RememberMeToken } from './token.js' -import type { GuardContract } from '../../auth/types.js' +import type { AuthClientResponse, GuardContract } from '../../auth/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../auth/symbols.js' import { AuthenticationException, InvalidCredentialsException } from '../../auth/errors.js' import type { @@ -629,4 +629,22 @@ export class SessionGuard }> { + const providerUser = await this.#userProvider.createUserForGuard(user) + const userId = providerUser.getId() + + debug('session_guard: returning client session for user id "%s"', userId) + return { + session: { + [this.sessionKeyName]: userId, + }, + } + } } From 008d89d6a18751f08f8ee3af65b0a232f66503d7 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 27 Oct 2023 11:25:29 +0530 Subject: [PATCH 34/96] refactor: remove unused imports --- src/guards/session/guard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts index 9309f0a..fb6403a 100644 --- a/src/guards/session/guard.ts +++ b/src/guards/session/guard.ts @@ -15,7 +15,7 @@ import { Exception, RuntimeException } from '@poppinss/utils' import debug from '../../auth/debug.js' import { RememberMeToken } from './token.js' -import type { AuthClientResponse, GuardContract } from '../../auth/types.js' +import type { GuardContract } from '../../auth/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../auth/symbols.js' import { AuthenticationException, InvalidCredentialsException } from '../../auth/errors.js' import type { From b725427efbfb740c939b8bd0ed77d96c573a70b5 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 27 Oct 2023 11:26:16 +0530 Subject: [PATCH 35/96] chore(release): 9.0.0-6 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 872e9ba..794555e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-5", + "version": "9.0.0-6", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", @@ -22,7 +22,7 @@ ".": "./build/index.js", "./types": "./build/src/auth/types.js", "./auth_provider": "./build/providers/auth_provider.js", - "./plugins/api_client": "./build/src/auth/plugins/japa/api_client.js", + "./plugins/api_client": "./build/src/auth/plugins/japa/api_client.js", "./services/main": "./build/services/auth.js", "./core/token": "./build/src/core/token.js", "./core/guard_user": "./build/src/core/guard_user.js", From cdcefff01095ffe1a771616019e1062ef7efb193 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 27 Oct 2023 14:36:56 +0530 Subject: [PATCH 36/96] feat: implement basic auth guard --- bin/test.ts | 4 + factories/basic_auth_guard_factory.ts | 33 +++ package.json | 5 +- src/auth/errors.ts | 18 ++ src/guards/basic_auth/define_config.ts | 50 ++++ src/guards/basic_auth/guard.ts | 232 +++++++++++++++++ src/guards/basic_auth/main.ts | 11 + src/guards/basic_auth/types.ts | 48 ++++ src/guards/session/guard.ts | 2 + tests/guards/basic_auth/authenticate.spec.ts | 250 +++++++++++++++++++ tests/guards/session/authenticate.spec.ts | 14 ++ tests/helpers.ts | 3 +- 12 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 factories/basic_auth_guard_factory.ts create mode 100644 src/guards/basic_auth/define_config.ts create mode 100644 src/guards/basic_auth/guard.ts create mode 100644 src/guards/basic_auth/main.ts create mode 100644 src/guards/basic_auth/types.ts create mode 100644 tests/guards/basic_auth/authenticate.spec.ts diff --git a/bin/test.ts b/bin/test.ts index 45bbeb5..dbe2451 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -11,6 +11,10 @@ configure({ name: 'session', files: ['tests/guards/session/**/*.spec.ts'], }, + { + name: 'basic_auth', + files: ['tests/guards/basic_auth/**/*.spec.ts'], + }, { name: 'auth', files: ['tests/auth/**/*.spec.ts'], diff --git a/factories/basic_auth_guard_factory.ts b/factories/basic_auth_guard_factory.ts new file mode 100644 index 0000000..a6fffe0 --- /dev/null +++ b/factories/basic_auth_guard_factory.ts @@ -0,0 +1,33 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import { + FactoryUser, + TestLucidUserProvider, + LucidUserProviderFactory, +} from './lucid_user_provider.js' +import { BasicAuthGuard } from '../src/guards/basic_auth/guard.js' +import type { UserProviderContract } from '../src/core/types.js' + +/** + * Exposes the API to create a basic auth guard for testing. Under + * the hood configures Lucid models for looking up users + */ +export class BasicAuthGuardFactory { + merge() { + return this + } + + create< + UserProvider extends UserProviderContract = TestLucidUserProvider, + >(ctx: HttpContext, provider?: UserProvider) { + return new BasicAuthGuard('basic', ctx, provider || new LucidUserProviderFactory().create()) + } +} diff --git a/package.json b/package.json index 794555e..fa1a593 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "./core/token_providers/*": "./build/src/core/token_providers/*.js", "./types/core": "./build/src/core/types.js", "./session": "./build/src/guards/session/main.js", + "./basic_auth": "./build/src/guards/basic_auth/main.js", "./initialize_auth_middleware": "./build/src/auth/middleware/initialize_auth_middleware.js", "./types/session": "./build/src/guards/session/types.js" }, @@ -82,6 +83,7 @@ "@japa/runner": "^3.0.4", "@japa/snapshot": "^2.0.0", "@swc/core": "1.3.82", + "@types/basic-auth": "^1.1.5", "@types/luxon": "^3.3.3", "@types/node": "^20.8.7", "@types/set-cookie-parser": "^2.4.5", @@ -131,7 +133,8 @@ ] }, "dependencies": { - "@poppinss/utils": "^6.5.0" + "@poppinss/utils": "^6.5.0", + "basic-auth": "^2.0.1" }, "peerDependencies": { "@adonisjs/core": "^6.1.5-31", diff --git a/src/auth/errors.ts b/src/auth/errors.ts index 2df4268..54eef51 100644 --- a/src/auth/errors.ts +++ b/src/auth/errors.ts @@ -31,6 +31,18 @@ export class AuthenticationException extends Exception { }) } + /** + * Raises authentication exception when session guard + * is unable to authenticate the request + */ + static E_INVALID_BASIC_AUTH_CREDENTIALS() { + return new AuthenticationException('Invalid basic auth credentials', { + code: 'E_INVALID_BASIC_AUTH_CREDENTIALS', + status: 401, + guardDriverName: 'basic_auth', + }) + } + guardDriverName: string redirectTo?: string identifier = 'auth.authenticate' @@ -104,6 +116,12 @@ export class AuthenticationException extends Exception { break } }, + basic_auth: (message, _, ctx) => { + ctx.response + .status(this.status) + .header('WWW-Authenticate', `Basic realm="Authenticate", charset="UTF-8"`) + .send(message) + }, } /** diff --git a/src/guards/basic_auth/define_config.ts b/src/guards/basic_auth/define_config.ts new file mode 100644 index 0000000..97764d9 --- /dev/null +++ b/src/guards/basic_auth/define_config.ts @@ -0,0 +1,50 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { configProvider } from '@adonisjs/core' +import { RuntimeException } from '@poppinss/utils' +import type { HttpContext } from '@adonisjs/core/http' +import type { ConfigProvider } from '@adonisjs/core/types' + +import type { GuardConfigProvider } from '../../auth/types.js' +import type { UserProviderContract } from '../../core/types.js' + +import { BasicAuthGuard } from './guard.js' + +/** + * Helper function to configure the basic auth guard for + * authentication. + * + * This method returns a config builder, which internally + * returns a factory function to construct a guard + * during HTTP requests. + */ +export function basicAuthGuard>(config: { + provider: ConfigProvider +}): GuardConfigProvider<(ctx: HttpContext) => BasicAuthGuard> { + return { + async resolver(guardName, app) { + const provider = await configProvider.resolve(app, config.provider) + if (!provider) { + throw new RuntimeException(`Invalid user provider defined on "${guardName}" guard`) + } + + const emitter = await app.container.make('emitter') + + /** + * Factory function needed by Authenticator to switch + * between guards and perform authentication + */ + return (ctx) => { + const guard = new BasicAuthGuard(guardName, ctx, provider) + return guard.withEmitter(emitter) + } + }, + } +} diff --git a/src/guards/basic_auth/guard.ts b/src/guards/basic_auth/guard.ts new file mode 100644 index 0000000..bdc376b --- /dev/null +++ b/src/guards/basic_auth/guard.ts @@ -0,0 +1,232 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import auth from 'basic-auth' +import type { Emitter } from '@adonisjs/core/events' +import type { HttpContext } from '@adonisjs/core/http' +import { Exception, RuntimeException } from '@poppinss/utils' + +import debug from '../../auth/debug.js' +import type { BasicAuthGuardEvents } from './types.js' +import type { GuardContract } from '../../auth/types.js' +import type { UserProviderContract } from '../../core/types.js' +import { AuthenticationException } from '../../auth/errors.js' +import { PROVIDER_REAL_USER, GUARD_KNOWN_EVENTS } from '../../auth/symbols.js' + +/** + * Implementation of basic auth as an authentication guard + */ +export class BasicAuthGuard> + implements GuardContract +{ + declare [GUARD_KNOWN_EVENTS]: BasicAuthGuardEvents + + /** + * A unique name for the guard. It is used while + * emitting events + */ + #name: string + + /** + * Reference to the current HTTP context + */ + #ctx: HttpContext + + /** + * Provider to lookup user details + */ + #userProvider: UserProvider + + /** + * Emitter to emit events + */ + #emitter?: Emitter> + + /** + * Driver name of the guard + */ + driverName: 'basic_auth' = 'basic_auth' + + /** + * Whether or not the authentication has been attempted + * during the current request + */ + authenticationAttempted = false + + /** + * A boolean to know if the current request has + * been authenticated + */ + isAuthenticated = false + + /** + * Reference to an instance of the authenticated or logged-in + * user. The value only exists after calling one of the + * following methods. + * + * - authenticate + * + * You can use the "getUserOrFail" method to throw an exception if + * the request is not authenticated. + */ + user?: UserProvider[typeof PROVIDER_REAL_USER] + + constructor(name: string, ctx: HttpContext, userProvider: UserProvider) { + this.#ctx = ctx + this.#name = name + this.#userProvider = userProvider + } + + /** + * Notifies about authentication failure and throws the exception + */ + #authenticationFailed(error: Exception): never { + if (this.#emitter) { + this.#emitter.emit('basic_auth:authentication_failed', { + guardName: this.#name, + error, + }) + } + + throw error + } + + /** + * Register an event emitter to listen for global events for + * authentication lifecycle. + */ + withEmitter(emitter: Emitter): this { + this.#emitter = emitter + return this + } + + /** + * Returns an instance of the authenticated user. Or throws + * an exception if the request is not authenticated. + */ + getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { + if (!this.user) { + throw AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS() + } + return this.user + } + + /** + * Verifies user credentials and returns an instance of + * the user or throws "E_INVALID_BASIC_AUTH_CREDENTIALS" exception. + */ + async verifyCredentials( + uid: string, + password: string + ): Promise { + debug('basic_auth_guard: attempting to verify credentials for uid "%s"', uid) + + /** + * Attempt to find a user by the uid and raise + * error when unable to find one + */ + const providerUser = await this.#userProvider.findByUid(uid) + if (!providerUser) { + this.#authenticationFailed(AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS()) + } + + /** + * Raise error when unable to verify password + */ + const user = providerUser.getOriginal() + + /** + * Raise error when unable to verify password + */ + if (!(await providerUser.verifyPassword(password))) { + this.#authenticationFailed(AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS()) + } + + return user + } + + /** + * Authenticates the current HTTP request for basic + * auth credentials + */ + async authenticate(): Promise { + /** + * Avoid re-authenticating when already authenticated + */ + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + + /** + * Beginning authentication attempt + */ + this.authenticationAttempted = true + if (this.#emitter) { + this.#emitter.emit('basic_auth:authentication_attempted', { + guardName: this.#name, + }) + } + + /** + * Fetch credentials from the header + */ + const credentials = auth(this.#ctx.request.request) + if (!credentials) { + this.#authenticationFailed(AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS()) + } + + debug('basic_auth_guard: authenticating user using credentials') + + /** + * Verifying user credentials + */ + this.user = await this.verifyCredentials(credentials.name, credentials.pass) + this.isAuthenticated = true + + debug('basic_auth_guard: marking user as authenticated') + + if (this.#emitter) { + this.#emitter.emit('basic_auth:authentication_succeeded', { + guardName: this.#name, + user: this.user, + }) + } + + /** + * Return user + */ + return this.getUserOrFail() + } + + /** + * Silently attempt to authenticate the user. + * + * The method returns a boolean indicating if the authentication + * succeeded or failed. + */ + async check(): Promise { + try { + await this.authenticate() + return true + } catch (error) { + if (error instanceof AuthenticationException) { + return false + } + + throw error + } + } + + /** + * Not support + */ + async authenticateAsClient(_: UserProvider[typeof PROVIDER_REAL_USER]): Promise { + throw new RuntimeException('Cannot authenticate as a client when using basic auth') + } +} diff --git a/src/guards/basic_auth/main.ts b/src/guards/basic_auth/main.ts new file mode 100644 index 0000000..f0f7b4b --- /dev/null +++ b/src/guards/basic_auth/main.ts @@ -0,0 +1,11 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export { BasicAuthGuard } from './guard.js' +export { basicAuthGuard } from './define_config.js' diff --git a/src/guards/basic_auth/types.ts b/src/guards/basic_auth/types.ts new file mode 100644 index 0000000..278d595 --- /dev/null +++ b/src/guards/basic_auth/types.ts @@ -0,0 +1,48 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Exception } from '@poppinss/utils' + +/** + * Events emitted by the basic auth guard + */ +export type BasicAuthGuardEvents = { + /** + * The event is emitted when the user credentials + * have been verified successfully. + */ + 'basic_auth:credentials_verified': { + guardName: string + uid: string + user: User + } + + /** + * Attempting to authenticate the user + */ + 'basic_auth:authentication_attempted': { + guardName: string + } + + /** + * Authentication was successful + */ + 'basic_auth:authentication_succeeded': { + guardName: string + user: User + } + + /** + * Authentication failed + */ + 'basic_auth:authentication_failed': { + guardName: string + error: Exception + } +} diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts index fb6403a..74effc9 100644 --- a/src/guards/session/guard.ts +++ b/src/guards/session/guard.ts @@ -425,6 +425,7 @@ export class SessionGuard { + test('authenticate user using credentials', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + + ctx.request.request.headers.authorization = `Basic ${Buffer.from( + `${user.email}:secret` + ).toString('base64')}` + + const [authSucceeded] = await Promise.all([ + pEvent(emitter, 'basic_auth:authentication_succeeded'), + basicAuthGuard.authenticate(), + ]) + + expectTypeOf(basicAuthGuard.authenticate).returns.toMatchTypeOf>() + assert.equal(authSucceeded!.user.id, user.id) + assert.equal(authSucceeded!.user.id, basicAuthGuard.getUserOrFail().id) + assert.equal(basicAuthGuard.getUserOrFail().id, user.id) + assert.isTrue(basicAuthGuard.isAuthenticated) + assert.isTrue(basicAuthGuard.authenticationAttempted) + }) + + test('check if user is logged in using check method', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + + ctx.request.request.headers.authorization = `Basic ${Buffer.from( + `${user.email}:secret` + ).toString('base64')}` + + const [authSucceeded, state] = await Promise.all([ + pEvent(emitter, 'basic_auth:authentication_succeeded'), + basicAuthGuard.check(), + ]) + + assert.isTrue(state) + expectTypeOf(basicAuthGuard.authenticate).returns.toMatchTypeOf>() + assert.equal(authSucceeded!.user.id, user.id) + assert.equal(authSucceeded!.user.id, basicAuthGuard.getUserOrFail().id) + assert.equal(basicAuthGuard.getUserOrFail().id, user.id) + assert.isTrue(basicAuthGuard.isAuthenticated) + assert.isTrue(basicAuthGuard.authenticationAttempted) + }) + + test('throw error when credentials are missing', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + + const [authFailed, authentication] = await Promise.allSettled([ + pEvent(emitter, 'basic_auth:authentication_failed'), + basicAuthGuard.authenticate(), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authentication.status, 'rejected') + + if (authFailed.status === 'fulfilled') { + assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') + } + if (authentication.status === 'rejected') { + assert.equal(authentication.reason.message, 'Invalid basic auth credentials') + } + + assert.isTrue(basicAuthGuard.authenticationAttempted) + assert.isFalse(basicAuthGuard.isAuthenticated) + assert.isUndefined(basicAuthGuard.user) + }) + + test('throw error when user does not exists', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + + ctx.request.request.headers.authorization = `Basic ${Buffer.from(`foo:secret`).toString( + 'base64' + )}` + + const [authFailed, authentication] = await Promise.allSettled([ + pEvent(emitter, 'basic_auth:authentication_failed'), + basicAuthGuard.authenticate(), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authentication.status, 'rejected') + + if (authFailed.status === 'fulfilled') { + assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') + } + if (authentication.status === 'rejected') { + assert.equal(authentication.reason.message, 'Invalid basic auth credentials') + } + + assert.isTrue(basicAuthGuard.authenticationAttempted) + assert.isFalse(basicAuthGuard.isAuthenticated) + assert.isUndefined(basicAuthGuard.user) + }) + + test('throw error when password is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + + ctx.request.request.headers.authorization = `Basic ${Buffer.from( + `${user.email}:wrongpassword` + ).toString('base64')}` + + const [authFailed, authentication] = await Promise.allSettled([ + pEvent(emitter, 'basic_auth:authentication_failed'), + basicAuthGuard.authenticate(), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authentication.status, 'rejected') + + if (authFailed.status === 'fulfilled') { + assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') + } + if (authentication.status === 'rejected') { + assert.equal(authentication.reason.message, 'Invalid basic auth credentials') + } + + assert.isTrue(basicAuthGuard.authenticationAttempted) + assert.isFalse(basicAuthGuard.isAuthenticated) + assert.isUndefined(basicAuthGuard.user) + }) + + test('throw error when called getUserOrFail', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + + const [authFailed, authentication] = await Promise.allSettled([ + pEvent(emitter, 'basic_auth:authentication_failed'), + basicAuthGuard.authenticate(), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authentication.status, 'rejected') + + if (authFailed.status === 'fulfilled') { + assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') + } + if (authentication.status === 'rejected') { + assert.equal(authentication.reason.message, 'Invalid basic auth credentials') + } + + assert.isTrue(basicAuthGuard.authenticationAttempted) + assert.isFalse(basicAuthGuard.isAuthenticated) + assert.throws(() => basicAuthGuard.getUserOrFail(), 'Invalid basic auth credentials') + }) + + test('throw error when calling check after authenticate and user is not authenticated', async ({ + assert, + }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + + ctx.request.request.headers.authorization = `Basic ${Buffer.from( + `${user.email}:wrongpassword` + ).toString('base64')}` + + const [authFailed, , authentication] = await Promise.allSettled([ + pEvent(emitter, 'basic_auth:authentication_failed'), + basicAuthGuard.check(), + basicAuthGuard.authenticate(), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authentication.status, 'rejected') + + if (authFailed.status === 'fulfilled') { + assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') + } + if (authentication.status === 'rejected') { + assert.equal(authentication.reason.message, 'Invalid basic auth credentials') + } + + assert.isTrue(basicAuthGuard.authenticationAttempted) + assert.isFalse(basicAuthGuard.isAuthenticated) + assert.isUndefined(basicAuthGuard.user) + }) + + test('throw error when calling authenticateAsClient', async () => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + const ctx = new HttpContextFactory().create() + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + await basicAuthGuard.authenticateAsClient(user) + }).throws('Cannot authenticate as a client when using basic auth') +}) diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts index d72be8a..e270555 100644 --- a/tests/guards/session/authenticate.spec.ts +++ b/tests/guards/session/authenticate.spec.ts @@ -370,4 +370,18 @@ test.group('Session guard | authenticate', () => { 'Invalid or expired authentication session' ) }) + + test('get authentication session via authenticateAsClient', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + + assert.deepEqual(await sessionGuard.authenticateAsClient(user), { + auth_web: user.id, + }) + }) }) diff --git a/tests/helpers.ts b/tests/helpers.ts index 142f8ce..0a9d792 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -25,6 +25,7 @@ import { EncryptionFactory } from '@adonisjs/core/factories/encryption' import { SessionGuardEvents } from '../src/guards/session/types.js' import { FactoryUser } from '../factories/lucid_user_provider.js' +import { BasicAuthGuardEvents } from '../src/guards/basic_auth/types.js' export const encryption: Encryption = new EncryptionFactory().create() @@ -114,7 +115,7 @@ export function createEmitter() { } const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) - return new Emitter>(app) + return new Emitter & BasicAuthGuardEvents>(app) } /** From 469050594d4c592e7387ab995cbd7b3226fe823f Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 27 Oct 2023 14:44:44 +0530 Subject: [PATCH 37/96] test: improve tests coverage --- tests/auth/authenticator_client.spec.ts | 78 +++++++++++++++++++++++ tests/auth/errors.spec.ts | 15 +++++ tests/guards/session/authenticate.spec.ts | 4 +- 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/auth/authenticator_client.spec.ts diff --git a/tests/auth/authenticator_client.spec.ts b/tests/auth/authenticator_client.spec.ts new file mode 100644 index 0000000..b28567c --- /dev/null +++ b/tests/auth/authenticator_client.spec.ts @@ -0,0 +1,78 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { FactoryUser } from '../../factories/lucid_user_provider.js' +import { createDatabase, createEmitter, createTables } from '../helpers.js' +import { SessionGuardFactory } from '../../factories/session_guard_factory.js' +import { AuthenticatorClient } from '../../src/auth/authenticator_client.js' + +test.group('Authenticator client', () => { + test('create authenticator client with guards', async ({ assert, expectTypeOf }) => { + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + + const client = new AuthenticatorClient({ + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + assert.instanceOf(client, AuthenticatorClient) + expectTypeOf(client.use).parameters.toMatchTypeOf<['web'?]>() + }) + + test('access guard using its name', async ({ assert, expectTypeOf }) => { + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + + const client = new AuthenticatorClient({ + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + const webGuard = client.use('web') + assert.strictEqual(webGuard, sessionGuard) + assert.equal(client.defaultGuard, 'web') + assert.equal(webGuard.driverName, 'session') + assert.strictEqual(client.use('web'), client.use('web')) + assert.strictEqual(client.use(), client.use('web')) + expectTypeOf(webGuard.user).toMatchTypeOf() + }) + + test('call authenticateAsClient via client', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + + const client = new AuthenticatorClient({ + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + assert.deepEqual(await client.use('web').authenticateAsClient(user), { + session: { + auth_web: user.id, + }, + }) + }) +}) diff --git a/tests/auth/errors.spec.ts b/tests/auth/errors.spec.ts index 5fee0a8..38f5f75 100644 --- a/tests/auth/errors.spec.ts +++ b/tests/auth/errors.spec.ts @@ -127,6 +127,21 @@ test.group('Errors | AuthenticationException', () => { assert.equal(ctx.response.getStatus(), 401) assert.equal(ctx.response.getBody(), 'Unauthorized access') }) + + test('handle basic auth exception with a prompt', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS() + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.equal( + ctx.response.getHeader('WWW-Authenticate'), + `Basic realm="Authenticate", charset="UTF-8"` + ) + }) }) test.group('Errors | InvalidCredentialsException', () => { diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts index e270555..523d543 100644 --- a/tests/guards/session/authenticate.spec.ts +++ b/tests/guards/session/authenticate.spec.ts @@ -381,7 +381,9 @@ test.group('Session guard | authenticate', () => { const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) assert.deepEqual(await sessionGuard.authenticateAsClient(user), { - auth_web: user.id, + session: { + auth_web: user.id, + }, }) }) }) From c61447f1986e1e0bf2b9fedf095d73184ee1dc08 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 27 Oct 2023 14:58:37 +0530 Subject: [PATCH 38/96] feat: add browser client plugin --- package.json | 14 +++- src/auth/plugins/japa/api_client.ts | 100 ++++++++++++++---------- src/auth/plugins/japa/browser_client.ts | 90 +++++++++++++++++++++ 3 files changed, 160 insertions(+), 44 deletions(-) create mode 100644 src/auth/plugins/japa/browser_client.ts diff --git a/package.json b/package.json index fa1a593..2d2abd9 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "./types": "./build/src/auth/types.js", "./auth_provider": "./build/providers/auth_provider.js", "./plugins/api_client": "./build/src/auth/plugins/japa/api_client.js", + "./plugins/browser_client": "./build/src/auth/plugins/japa/browser_client.js", "./services/main": "./build/services/auth.js", "./core/token": "./build/src/core/token.js", "./core/guard_user": "./build/src/core/guard_user.js", @@ -78,8 +79,10 @@ "@commitlint/config-conventional": "^18.0.0", "@japa/api-client": "^2.0.0", "@japa/assert": "^2.0.0", + "@japa/browser-client": "^2.0.0", "@japa/expect-type": "^2.0.0", "@japa/file-system": "^2.0.0", + "@japa/plugin-adonisjs": "^2.0.0", "@japa/runner": "^3.0.4", "@japa/snapshot": "^2.0.0", "@swc/core": "1.3.82", @@ -96,6 +99,7 @@ "husky": "^8.0.3", "luxon": "^3.4.3", "np": "^8.0.4", + "playwright": "^1.39.0", "prettier": "^3.0.3", "set-cookie-parser": "^2.6.0", "sqlite3": "^5.1.6", @@ -140,7 +144,9 @@ "@adonisjs/core": "^6.1.5-31", "@adonisjs/lucid": "^19.0.0-3", "@adonisjs/session": "^7.0.0-13", - "@japa/api-client": "^2.0.0" + "@japa/api-client": "^2.0.0", + "@japa/browser-client": "^2.0.0", + "@japa/plugin-adonisjs": "^2.0.0" }, "peerDependenciesMeta": { "@adonisjs/lucid": { @@ -151,6 +157,12 @@ }, "@japa/api-client": { "optional": true + }, + "@japa/browser-client": { + "optional": true + }, + "@japa/plugin-adonisjs": { + "optional": true } } } diff --git a/src/auth/plugins/japa/api_client.ts b/src/auth/plugins/japa/api_client.ts index 01791ff..d99dd2f 100644 --- a/src/auth/plugins/japa/api_client.ts +++ b/src/auth/plugins/japa/api_client.ts @@ -9,8 +9,11 @@ /// +import type { PluginFn } from '@japa/runner/types' import { ApiClient, ApiRequest } from '@japa/api-client' import type { ApplicationService } from '@adonisjs/core/types' + +import debug from '../../debug.js' import type { Authenticators, GuardContract, GuardFactory } from '../../types.js' declare module '@japa/api-client' { @@ -58,51 +61,62 @@ declare module '@japa/api-client' { * HTTP requests using the Japa API client */ export const authApiClient = (app: ApplicationService) => { - ApiRequest.macro('loginAs', function (this: ApiRequest, user) { - this.authData = { - guard: '__default__', - user: user, - } - return this - }) + const pluginFn: PluginFn = function () { + debug('installing auth api client plugin') - ApiRequest.macro('withGuard', function < - K extends keyof Authenticators, - Self extends ApiRequest, - >(this: Self, guard: K) { - return { - loginAs: (user) => { - this.authData = { - guard, - user: user, - } - return this - }, - } - }) + ApiRequest.macro('loginAs', function (this: ApiRequest, user) { + this.authData = { + guard: '__default__', + user: user, + } + return this + }) - /** - * Hook into the request and login the user - */ - ApiClient.setup(async (request) => { - const auth = await app.container.make('auth.manager') - const authData = request['authData'] - if (!authData) { - return - } + ApiRequest.macro('withGuard', function < + K extends keyof Authenticators, + Self extends ApiRequest, + >(this: Self, guard: K) { + return { + loginAs: (user) => { + this.authData = { + guard, + user: user, + } + return this + }, + } + }) + + /** + * Hook into the request and login the user + */ + ApiClient.setup(async (request) => { + const auth = await app.container.make('auth.manager') + const authData = request['authData'] + if (!authData) { + return + } - const client = auth.createAuthenticatorClient() - const guard = authData.guard === '__default__' ? client.use() : client.use(authData.guard) - const requestData = await (guard as GuardContract).authenticateAsClient(authData.user) + const client = auth.createAuthenticatorClient() + const guard = authData.guard === '__default__' ? client.use() : client.use(authData.guard) + const requestData = await (guard as GuardContract).authenticateAsClient( + authData.user + ) - if (requestData.headers) { - request.headers(requestData.headers) - } - if (requestData.session) { - request.withSession(requestData.session) - } - if (requestData.cookies) { - request.cookies(requestData.cookies) - } - }) + if (requestData.headers) { + debug('defining headers with api client request %O', requestData.headers) + request.headers(requestData.headers) + } + if (requestData.session) { + debug('defining session with api client request %O', requestData.session) + request.withSession(requestData.session) + } + if (requestData.cookies) { + debug('defining session with api client request %O', requestData.session) + request.cookies(requestData.cookies) + } + }) + } + + return pluginFn } diff --git a/src/auth/plugins/japa/browser_client.ts b/src/auth/plugins/japa/browser_client.ts new file mode 100644 index 0000000..40591d7 --- /dev/null +++ b/src/auth/plugins/japa/browser_client.ts @@ -0,0 +1,90 @@ +/* + * @adoniss/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// +/// + +import { RuntimeException } from '@poppinss/utils' +import type { PluginFn } from '@japa/runner/types' +import { decoratorsCollection } from '@japa/browser-client' +import type { ApplicationService } from '@adonisjs/core/types' + +import debug from '../../debug.js' +import type { Authenticators, GuardContract, GuardFactory } from '../../types.js' + +declare module 'playwright' { + export interface BrowserContext { + /** + * Login a user using the default authentication + * guard when using the browser context to + * make page visits + */ + loginAs(user: { + [K in keyof Authenticators]: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never + : never + }): Promise + + /** + * Define the authentication guard for login + */ + withGuard( + guard: K + ): { + /** + * Login a user using a specific auth guard + */ + loginAs( + user: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never + : never + ): Promise + } + } +} + +export const authBrowserClient = (app: ApplicationService) => { + const pluginFn: PluginFn = async function () { + debug('installing auth browser client plugin') + + const auth = await app.container.make('auth.manager') + + decoratorsCollection.register({ + context(context) { + context.loginAs = async function (user) { + const client = auth.createAuthenticatorClient() + const guard = client.use() as GuardContract + const requestData = await guard.authenticateAsClient(user) + + if (requestData.headers) { + throw new RuntimeException(`Cannot use "${guard.driverName}" guard with browser client`) + } + + if (requestData.cookies) { + debug('defining cookies with browser context %O', requestData.cookies) + Object.keys(requestData.cookies).forEach((cookie) => { + context.setCookie(cookie, requestData.cookies![cookie]) + }) + } + + if (requestData.session) { + debug('defining session with browser context %O', requestData.session) + context.setSession(requestData.session) + } + } + }, + }) + } + + return pluginFn +} From 18709e573ae2b6f86c752aadec08907799b65a37 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 27 Oct 2023 15:10:04 +0530 Subject: [PATCH 39/96] chore(release): 9.0.0-7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2d2abd9..7a80504 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-6", + "version": "9.0.0-7", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From 722ae479ae9027996c05809cced4f9a06a400352 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 30 Oct 2023 16:53:15 +0530 Subject: [PATCH 40/96] refactor: remove loginRoute from auth config --- src/auth/define_config.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/auth/define_config.ts b/src/auth/define_config.ts index 1b823a9..62623a7 100644 --- a/src/auth/define_config.ts +++ b/src/auth/define_config.ts @@ -27,7 +27,6 @@ export type ResolvedAuthConfig< KnownGuards extends Record>, > = { default: keyof KnownGuards - loginRoute: string guards: { [K in keyof KnownGuards]: KnownGuards[K] extends GuardConfigProvider ? A @@ -44,7 +43,6 @@ export function defineConfig< KnownGuards extends Record>, >(config: { default: keyof KnownGuards - loginRoute: string guards: KnownGuards }): ConfigProvider> { return configProvider.create(async (app) => { @@ -62,7 +60,6 @@ export function defineConfig< return { default: config.default, - loginRoute: config.loginRoute, guards: guards, } as ResolvedAuthConfig }) From d9a7aa71e04c7ee46440f8e369cd7d50d303ee96 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 30 Oct 2023 17:00:28 +0530 Subject: [PATCH 41/96] chore: fix breaking build --- tests/auth/define_config.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts index fa6ea27..b94402f 100644 --- a/tests/auth/define_config.spec.ts +++ b/tests/auth/define_config.spec.ts @@ -69,7 +69,6 @@ test.group('Define config', () => { const authConfigProvider = defineConfig({ default: 'web', - loginRoute: '/login', guards: { web: sessionGuard({ provider: lucidConfigProvider, @@ -96,7 +95,6 @@ test.group('Define config', () => { const authConfigProvider = defineConfig({ default: 'web', - loginRoute: '/login', guards: { web: sessionGuard({ provider: lucidConfigProvider, From fc1a36092a3864de293d38655fe077f0348d0414 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 30 Oct 2023 17:08:58 +0530 Subject: [PATCH 42/96] chore(release): 9.0.0-8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7a80504..4f79447 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-7", + "version": "9.0.0-8", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From 6534a9883897828cceaba6559e4b2f394cadd6b7 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 27 Nov 2023 16:40:22 +0530 Subject: [PATCH 43/96] chore: update dependencies --- package.json | 68 ++++++++++++++--------------- src/core/user_providers/database.ts | 4 +- tests/helpers.ts | 4 +- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 4f79447..01d0808 100644 --- a/package.json +++ b/package.json @@ -67,45 +67,45 @@ "url": "https://github.com/adonisjs/auth/issues" }, "devDependencies": { - "@adonisjs/assembler": "^6.1.3-25", - "@adonisjs/core": "^6.1.5-31", - "@adonisjs/eslint-config": "^1.1.8", - "@adonisjs/i18n": "^2.0.0-6", - "@adonisjs/lucid": "^19.0.0-3", - "@adonisjs/prettier-config": "^1.1.8", - "@adonisjs/session": "^7.0.0-13", - "@adonisjs/tsconfig": "^1.1.8", - "@commitlint/cli": "^18.0.0", - "@commitlint/config-conventional": "^18.0.0", - "@japa/api-client": "^2.0.0", - "@japa/assert": "^2.0.0", - "@japa/browser-client": "^2.0.0", + "@adonisjs/assembler": "^6.1.3-28", + "@adonisjs/core": "^6.1.5-32", + "@adonisjs/eslint-config": "^1.1.9", + "@adonisjs/i18n": "^2.0.0-8", + "@adonisjs/lucid": "^19.0.0-4", + "@adonisjs/prettier-config": "^1.1.9", + "@adonisjs/session": "^7.0.0-14", + "@adonisjs/tsconfig": "^1.1.9", + "@commitlint/cli": "^18.4.3", + "@commitlint/config-conventional": "^18.4.3", + "@japa/api-client": "^2.0.1", + "@japa/assert": "^2.0.1", + "@japa/browser-client": "^2.0.1", "@japa/expect-type": "^2.0.0", - "@japa/file-system": "^2.0.0", - "@japa/plugin-adonisjs": "^2.0.0", - "@japa/runner": "^3.0.4", - "@japa/snapshot": "^2.0.0", - "@swc/core": "1.3.82", - "@types/basic-auth": "^1.1.5", - "@types/luxon": "^3.3.3", - "@types/node": "^20.8.7", - "@types/set-cookie-parser": "^2.4.5", + "@japa/file-system": "^2.0.1", + "@japa/plugin-adonisjs": "^2.0.1", + "@japa/runner": "^3.1.0", + "@japa/snapshot": "^2.0.3", + "@swc/core": "^1.3.99", + "@types/basic-auth": "^1.1.6", + "@types/luxon": "^3.3.5", + "@types/node": "^20.10.0", + "@types/set-cookie-parser": "^2.4.7", "c8": "^8.0.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", - "eslint": "^8.52.0", + "eslint": "^8.54.0", "github-label-sync": "^2.3.1", "husky": "^8.0.3", - "luxon": "^3.4.3", + "luxon": "^3.4.4", "np": "^8.0.4", - "playwright": "^1.39.0", - "prettier": "^3.0.3", + "playwright": "^1.40.0", + "prettier": "^3.1.0", "set-cookie-parser": "^2.6.0", "sqlite3": "^5.1.6", "timekeeper": "^2.3.1", "ts-node": "^10.9.1", - "typescript": "^5.2.2" + "typescript": "5.2.2" }, "prettier": "@adonisjs/prettier-config", "eslintConfig": { @@ -137,16 +137,16 @@ ] }, "dependencies": { - "@poppinss/utils": "^6.5.0", + "@poppinss/utils": "^6.5.1", "basic-auth": "^2.0.1" }, "peerDependencies": { - "@adonisjs/core": "^6.1.5-31", - "@adonisjs/lucid": "^19.0.0-3", - "@adonisjs/session": "^7.0.0-13", - "@japa/api-client": "^2.0.0", - "@japa/browser-client": "^2.0.0", - "@japa/plugin-adonisjs": "^2.0.0" + "@adonisjs/core": "^6.1.5-32", + "@adonisjs/lucid": "^19.0.0-4", + "@adonisjs/session": "^7.0.0-14", + "@japa/api-client": "^2.0.1", + "@japa/browser-client": "^2.0.1", + "@japa/plugin-adonisjs": "^2.0.1" }, "peerDependenciesMeta": { "@adonisjs/lucid": { diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts index ea99031..b2e7451 100644 --- a/src/core/user_providers/database.ts +++ b/src/core/user_providers/database.ts @@ -57,9 +57,7 @@ class DatabaseUser> extends GuardUser Date: Mon, 27 Nov 2023 16:43:45 +0530 Subject: [PATCH 44/96] fix: implement withGuard method on browser client plugin --- src/auth/plugins/japa/browser_client.ts | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/auth/plugins/japa/browser_client.ts b/src/auth/plugins/japa/browser_client.ts index 40591d7..7a64227 100644 --- a/src/auth/plugins/japa/browser_client.ts +++ b/src/auth/plugins/japa/browser_client.ts @@ -61,6 +61,34 @@ export const authBrowserClient = (app: ApplicationService) => { decoratorsCollection.register({ context(context) { + context.withGuard = function (guardName) { + return { + async loginAs(user) { + const client = auth.createAuthenticatorClient() + const guard = client.use(guardName) as GuardContract + const requestData = await guard.authenticateAsClient(user) + + if (requestData.headers) { + throw new RuntimeException( + `Cannot use "${guard.driverName}" guard with browser client` + ) + } + + if (requestData.cookies) { + debug('defining cookies with browser context %O', requestData.cookies) + Object.keys(requestData.cookies).forEach((cookie) => { + context.setCookie(cookie, requestData.cookies![cookie]) + }) + } + + if (requestData.session) { + debug('defining session with browser context %O', requestData.session) + context.setSession(requestData.session) + } + }, + } + } + context.loginAs = async function (user) { const client = auth.createAuthenticatorClient() const guard = client.use() as GuardContract From 5ca5cf3b1d6dd067250493123698cd62fcbf1858 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 27 Nov 2023 16:53:59 +0530 Subject: [PATCH 45/96] chore(release): 9.0.0-9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 01d0808..ddb0b59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-8", + "version": "9.0.0-9", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From 24dedf79d2963f5d873729d9d57c6b6b25cd73a8 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 26 Dec 2023 13:22:59 +0530 Subject: [PATCH 46/96] refactor: use preset for configure hook --- configure.ts | 39 +++------------- index.ts | 1 - package.json | 61 +++++++++++++------------- stubs/main.ts | 12 ----- stubs/middleware/auth_middleware.stub | 30 ------------- stubs/middleware/guest_middleware.stub | 36 --------------- tests/auth/configure.spec.ts | 44 +++---------------- 7 files changed, 43 insertions(+), 180 deletions(-) delete mode 100644 stubs/main.ts delete mode 100644 stubs/middleware/auth_middleware.stub delete mode 100644 stubs/middleware/guest_middleware.stub diff --git a/configure.ts b/configure.ts index 888bd51..92d7400 100644 --- a/configure.ts +++ b/configure.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import { presetAuth } from '@adonisjs/presets/auth' import type Configure from '@adonisjs/core/commands/configure' /** @@ -14,40 +15,10 @@ import type Configure from '@adonisjs/core/commands/configure' */ export async function configure(command: Configure) { const codemods = await command.createCodemods() + // let guard: string | undefined = command.parsedFlags.guard - /** - * Publish middleware to user application - */ - await command.publishStub('middleware/auth_middleware.stub', { - entity: command.app.generators.createEntity('auth'), + await presetAuth(codemods, command.app, { + guard: 'session', + userProvider: 'lucid', }) - await command.publishStub('middleware/guest_middleware.stub', { - entity: command.app.generators.createEntity('guest'), - }) - - /** - * Register provider - */ - await codemods.updateRcFile((rcFile) => { - rcFile.addProvider('@adonisjs/auth/auth_provider') - }) - - /** - * Register middleware - */ - await codemods.registerMiddleware('router', [ - { - path: '@adonisjs/auth/initialize_auth_middleware', - }, - ]) - await codemods.registerMiddleware('named', [ - { - name: 'auth', - path: '#middleware/auth_middleware', - }, - { - name: 'guest', - path: '#middleware/guest_middleware', - }, - ]) } diff --git a/index.ts b/index.ts index c8b9adc..9c27bef 100644 --- a/index.ts +++ b/index.ts @@ -8,7 +8,6 @@ */ export { configure } from './configure.js' -export { stubsRoot } from './stubs/main.js' export * as symbols from './src/auth/symbols.js' export { AuthManager } from './src/auth/auth_manager.js' export { Authenticator } from './src/auth/authenticator.js' diff --git a/package.json b/package.json index ddb0b59..5b7ec5a 100644 --- a/package.json +++ b/package.json @@ -67,45 +67,45 @@ "url": "https://github.com/adonisjs/auth/issues" }, "devDependencies": { - "@adonisjs/assembler": "^6.1.3-28", - "@adonisjs/core": "^6.1.5-32", - "@adonisjs/eslint-config": "^1.1.9", - "@adonisjs/i18n": "^2.0.0-8", - "@adonisjs/lucid": "^19.0.0-4", - "@adonisjs/prettier-config": "^1.1.9", - "@adonisjs/session": "^7.0.0-14", - "@adonisjs/tsconfig": "^1.1.9", + "@adonisjs/assembler": "^7.0.0-1", + "@adonisjs/core": "^6.1.5-36", + "@adonisjs/eslint-config": "^1.2.0", + "@adonisjs/i18n": "^2.0.0-9", + "@adonisjs/lucid": "^19.0.0-8", + "@adonisjs/prettier-config": "^1.2.0", + "@adonisjs/session": "^7.0.0-15", + "@adonisjs/tsconfig": "^1.2.0", "@commitlint/cli": "^18.4.3", "@commitlint/config-conventional": "^18.4.3", - "@japa/api-client": "^2.0.1", - "@japa/assert": "^2.0.1", - "@japa/browser-client": "^2.0.1", - "@japa/expect-type": "^2.0.0", - "@japa/file-system": "^2.0.1", + "@japa/api-client": "^2.0.2", + "@japa/assert": "^2.1.0", + "@japa/browser-client": "^2.0.2", + "@japa/expect-type": "^2.0.1", + "@japa/file-system": "^2.1.1", "@japa/plugin-adonisjs": "^2.0.1", - "@japa/runner": "^3.1.0", - "@japa/snapshot": "^2.0.3", - "@swc/core": "^1.3.99", + "@japa/runner": "^3.1.1", + "@japa/snapshot": "^2.0.4", + "@swc/core": "^1.3.101", "@types/basic-auth": "^1.1.6", - "@types/luxon": "^3.3.5", - "@types/node": "^20.10.0", + "@types/luxon": "^3.3.7", + "@types/node": "^20.10.5", "@types/set-cookie-parser": "^2.4.7", "c8": "^8.0.1", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", - "eslint": "^8.54.0", + "eslint": "^8.56.0", "github-label-sync": "^2.3.1", "husky": "^8.0.3", "luxon": "^3.4.4", - "np": "^8.0.4", - "playwright": "^1.40.0", - "prettier": "^3.1.0", + "np": "^9.2.0", + "playwright": "^1.40.1", + "prettier": "^3.1.1", "set-cookie-parser": "^2.6.0", "sqlite3": "^5.1.6", "timekeeper": "^2.3.1", - "ts-node": "^10.9.1", - "typescript": "5.2.2" + "ts-node": "^10.9.2", + "typescript": "^5.3.3" }, "prettier": "@adonisjs/prettier-config", "eslintConfig": { @@ -137,15 +137,16 @@ ] }, "dependencies": { - "@poppinss/utils": "^6.5.1", + "@adonisjs/presets": "^2.1.0", + "@poppinss/utils": "^6.7.0", "basic-auth": "^2.0.1" }, "peerDependencies": { - "@adonisjs/core": "^6.1.5-32", - "@adonisjs/lucid": "^19.0.0-4", - "@adonisjs/session": "^7.0.0-14", - "@japa/api-client": "^2.0.1", - "@japa/browser-client": "^2.0.1", + "@adonisjs/core": "^6.1.5-36", + "@adonisjs/lucid": "^19.0.0-8", + "@adonisjs/session": "^7.0.0-15", + "@japa/api-client": "^2.0.2", + "@japa/browser-client": "^2.0.2", "@japa/plugin-adonisjs": "^2.0.1" }, "peerDependenciesMeta": { diff --git a/stubs/main.ts b/stubs/main.ts deleted file mode 100644 index ce97e2d..0000000 --- a/stubs/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { getDirname } from '@poppinss/utils' - -export const stubsRoot = getDirname(import.meta.url) diff --git a/stubs/middleware/auth_middleware.stub b/stubs/middleware/auth_middleware.stub deleted file mode 100644 index 1d4976a..0000000 --- a/stubs/middleware/auth_middleware.stub +++ /dev/null @@ -1,30 +0,0 @@ -{{#var middlewareName = generators.middlewareName(entity.name)}} -{{#var middlewareFileName = generators.middlewareFileName(entity.name)}} -{{{ - exports({ to: app.middlewarePath(entity.path, middlewareFileName) }) -}}} -import type { HttpContext } from '@adonisjs/core/http' -import type { NextFn } from '@adonisjs/core/types/http' -import type { Authenticators } from '@adonisjs/auth/types' - -/** - * Auth middleware is used authenticate HTTP requests and deny - * access to unauthenticated users. - */ -export default class {{ middlewareName }} { - /** - * The URL to redirect to, when authentication fails - */ - redirectTo = '/login' - - async handle( - ctx: HttpContext, - next: NextFn, - options: { - guards?: (keyof Authenticators)[] - } = {} - ) { - await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo }) - return next() - } -} diff --git a/stubs/middleware/guest_middleware.stub b/stubs/middleware/guest_middleware.stub deleted file mode 100644 index ea6d7dc..0000000 --- a/stubs/middleware/guest_middleware.stub +++ /dev/null @@ -1,36 +0,0 @@ -{{#var middlewareName = generators.middlewareName(entity.name)}} -{{#var middlewareFileName = generators.middlewareFileName(entity.name)}} -{{{ - exports({ to: app.middlewarePath(entity.path, middlewareFileName) }) -}}} -import type { HttpContext } from '@adonisjs/core/http' -import type { NextFn } from '@adonisjs/core/types/http' -import type { Authenticators } from '@adonisjs/auth/types' - -/** - * Guest middleware is used to deny access to routes that should - * be accessed by unauthenticated users. - * - * For example, the login page should not be accessible if the user - * is already logged-in - */ -export default class {{ middlewareName }} { - /** - * The URL to redirect to when user is logged-in - */ - redirectTo = '/' - - async handle( - ctx: HttpContext, - next: NextFn, - options: { guards?: (keyof Authenticators)[] } = {} - ) { - for (let guard of options.guards || [ctx.auth.defaultGuard]) { - if (await ctx.auth.use(guard).check()) { - return ctx.response.redirect(this.redirectTo, true) - } - } - - return next() - } -} diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.spec.ts index f6a303d..31e2875 100644 --- a/tests/auth/configure.spec.ts +++ b/tests/auth/configure.spec.ts @@ -20,37 +20,9 @@ test.group('Configure', (group) => { context.fs.basePath = fileURLToPath(BASE_URL) }) - test('register provider', async ({ fs, assert }) => { - const ignitor = new IgnitorFactory() - .withCoreProviders() - .withCoreConfig() - .create(BASE_URL, { - importer: (filePath) => { - if (filePath.startsWith('./') || filePath.startsWith('../')) { - return import(new URL(filePath, BASE_URL).href) - } - - return import(filePath) - }, - }) - - await fs.create('start/kernel.ts', `router.use([])`) - await fs.createJson('tsconfig.json', {}) - await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) + group.each.disableTimeout() - const app = ignitor.createApp('web') - await app.init() - await app.boot() - - const ace = await app.container.make('ace') - const command = await ace.create(Configure, ['../../../index.js']) - await command.exec() - - await assert.fileExists('adonisrc.ts') - await assert.fileContains('adonisrc.ts', '@adonisjs/auth/auth_provider') - }).timeout(60 * 1000) - - test('register middleware', async ({ fs, assert }) => { + test('register provider and middleware', async ({ fs, assert }) => { const ignitor = new IgnitorFactory() .withCoreProviders() .withCoreConfig() @@ -66,11 +38,9 @@ test.group('Configure', (group) => { await fs.create( 'start/kernel.ts', - ` - router.use([]) + `router.use([]) export const { middleware } = router.named({ - }) - ` + })` ) await fs.createJson('tsconfig.json', {}) await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) @@ -80,10 +50,10 @@ test.group('Configure', (group) => { await app.boot() const ace = await app.container.make('ace') - const command = await ace.create(Configure, ['../../../index.js']) + const command = await ace.create(Configure, ['../../../index.js', '--guard=session']) await command.exec() - await assert.fileExists('adonisrc.ts') + await assert.fileContains('adonisrc.ts', '@adonisjs/auth/auth_provider') await assert.fileExists('app/middleware/auth_middleware.ts') await assert.fileExists('app/middleware/guest_middleware.ts') @@ -98,5 +68,5 @@ test.group('Configure', (group) => { 'start/kernel.ts', `router.use([() => import('@adonisjs/auth/initialize_auth_middleware')])` ) - }).timeout(60 * 1000) + }) }) From 7275fd4ca762a3bcd3b60b75149b7cb3cf7275b1 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 26 Dec 2023 13:26:47 +0530 Subject: [PATCH 47/96] refactor: use session.flashError method to flash auth errors --- src/auth/errors.ts | 4 ++-- tests/auth/errors.spec.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auth/errors.ts b/src/auth/errors.ts index 54eef51..2afe2b6 100644 --- a/src/auth/errors.ts +++ b/src/auth/errors.ts @@ -92,7 +92,7 @@ export class AuthenticationException extends Exception { case 'html': case null: ctx.session.flashExcept(['_csrf']) - ctx.session.flash({ errors: { [error.identifier]: [message] } }) + ctx.session.flashErrors({ [error.identifier]: [message] }) ctx.response.redirect(error.redirectTo || '/', true) break case 'json': @@ -201,7 +201,7 @@ export class InvalidCredentialsException extends Exception { case 'html': case null: ctx.session.flashExcept(['_csrf']) - ctx.session.flash({ errors: { [this.identifier]: [message] } }) + ctx.session.flashErrors({ [this.identifier]: [message] }) ctx.response.redirect().withQs().back() break case 'json': diff --git a/tests/auth/errors.spec.ts b/tests/auth/errors.spec.ts index 38f5f75..975bf9a 100644 --- a/tests/auth/errors.spec.ts +++ b/tests/auth/errors.spec.ts @@ -25,7 +25,7 @@ test.group('Errors | AuthenticationException', () => { }) assert.deepEqual(ctx.session.responseFlashMessages.all(), { - errors: { 'auth.authenticate': ['Unauthorized access'] }, + errorsBag: { 'auth.authenticate': ['Unauthorized access'] }, input: {}, }) assert.equal(ctx.response.getHeader('location'), '/') @@ -46,7 +46,7 @@ test.group('Errors | AuthenticationException', () => { }) assert.deepEqual(ctx.session.responseFlashMessages.all(), { - errors: { 'auth.authenticate': ['Unauthorized access'] }, + errorsBag: { 'auth.authenticate': ['Unauthorized access'] }, input: {}, }) assert.equal(ctx.response.getHeader('location'), '/login') @@ -155,7 +155,7 @@ test.group('Errors | InvalidCredentialsException', () => { }) assert.deepEqual(ctx.session.responseFlashMessages.all(), { - errors: { 'auth.login': ['Invalid credentials'] }, + errorsBag: { 'auth.login': ['Invalid credentials'] }, input: {}, }) assert.equal(ctx.response.getHeader('location'), '/') From d13572b1675a42e7073ffe84ea0005137b757c2f Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 26 Dec 2023 13:35:00 +0530 Subject: [PATCH 48/96] refactor: pass HttpContext with all auth events --- src/guards/basic_auth/define_config.ts | 2 +- src/guards/basic_auth/guard.ts | 9 ++++++--- src/guards/basic_auth/types.ts | 5 +++++ src/guards/session/define_config.ts | 2 +- src/guards/session/guard.ts | 20 ++++++++++++++++---- src/guards/session/types.ts | 9 +++++++++ tests/auth/auth_manager.spec.ts | 2 +- tests/auth/authenticator.spec.ts | 8 ++++---- tests/auth/authenticator_client.spec.ts | 6 +++--- tests/guards/basic_auth/authenticate.spec.ts | 16 ++++++++-------- tests/guards/session/attempt.spec.ts | 6 +++--- tests/guards/session/authenticate.spec.ts | 12 ++++++------ tests/guards/session/login.spec.ts | 2 +- tests/guards/session/login_via_id.spec.ts | 4 ++-- tests/guards/session/logout.spec.ts | 2 +- 15 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/guards/basic_auth/define_config.ts b/src/guards/basic_auth/define_config.ts index 97764d9..cb233c5 100644 --- a/src/guards/basic_auth/define_config.ts +++ b/src/guards/basic_auth/define_config.ts @@ -43,7 +43,7 @@ export function basicAuthGuard { const guard = new BasicAuthGuard(guardName, ctx, provider) - return guard.withEmitter(emitter) + return guard.setEmitter(emitter) } }, } diff --git a/src/guards/basic_auth/guard.ts b/src/guards/basic_auth/guard.ts index bdc376b..17b84d7 100644 --- a/src/guards/basic_auth/guard.ts +++ b/src/guards/basic_auth/guard.ts @@ -8,8 +8,8 @@ */ import auth from 'basic-auth' -import type { Emitter } from '@adonisjs/core/events' import type { HttpContext } from '@adonisjs/core/http' +import type { EmitterLike } from '@adonisjs/core/types/events' import { Exception, RuntimeException } from '@poppinss/utils' import debug from '../../auth/debug.js' @@ -46,7 +46,7 @@ export class BasicAuthGuard> /** * Emitter to emit events */ - #emitter?: Emitter> + #emitter?: EmitterLike> /** * Driver name of the guard @@ -89,6 +89,7 @@ export class BasicAuthGuard> #authenticationFailed(error: Exception): never { if (this.#emitter) { this.#emitter.emit('basic_auth:authentication_failed', { + ctx: this.#ctx, guardName: this.#name, error, }) @@ -101,7 +102,7 @@ export class BasicAuthGuard> * Register an event emitter to listen for global events for * authentication lifecycle. */ - withEmitter(emitter: Emitter): this { + setEmitter(emitter: EmitterLike): this { this.#emitter = emitter return this } @@ -169,6 +170,7 @@ export class BasicAuthGuard> this.authenticationAttempted = true if (this.#emitter) { this.#emitter.emit('basic_auth:authentication_attempted', { + ctx: this.#ctx, guardName: this.#name, }) } @@ -193,6 +195,7 @@ export class BasicAuthGuard> if (this.#emitter) { this.#emitter.emit('basic_auth:authentication_succeeded', { + ctx: this.#ctx, guardName: this.#name, user: this.user, }) diff --git a/src/guards/basic_auth/types.ts b/src/guards/basic_auth/types.ts index 278d595..df4ca21 100644 --- a/src/guards/basic_auth/types.ts +++ b/src/guards/basic_auth/types.ts @@ -8,6 +8,7 @@ */ import { Exception } from '@poppinss/utils' +import type { HttpContext } from '@adonisjs/core/http' /** * Events emitted by the basic auth guard @@ -18,6 +19,7 @@ export type BasicAuthGuardEvents = { * have been verified successfully. */ 'basic_auth:credentials_verified': { + ctx: HttpContext guardName: string uid: string user: User @@ -27,6 +29,7 @@ export type BasicAuthGuardEvents = { * Attempting to authenticate the user */ 'basic_auth:authentication_attempted': { + ctx: HttpContext guardName: string } @@ -34,6 +37,7 @@ export type BasicAuthGuardEvents = { * Authentication was successful */ 'basic_auth:authentication_succeeded': { + ctx: HttpContext guardName: string user: User } @@ -42,6 +46,7 @@ export type BasicAuthGuardEvents = { * Authentication failed */ 'basic_auth:authentication_failed': { + ctx: HttpContext guardName: string error: Exception } diff --git a/src/guards/session/define_config.ts b/src/guards/session/define_config.ts index 9de5f32..d1a9eff 100644 --- a/src/guards/session/define_config.ts +++ b/src/guards/session/define_config.ts @@ -57,7 +57,7 @@ export function sessionGuard -import { Emitter } from '@adonisjs/core/events' import type { HttpContext } from '@adonisjs/core/http' import { Exception, RuntimeException } from '@poppinss/utils' +import type { EmitterLike } from '@adonisjs/core/types/events' import debug from '../../auth/debug.js' import { RememberMeToken } from './token.js' @@ -64,7 +64,7 @@ export class SessionGuard> + #emitter?: EmitterLike> /** * Driver name of the guard @@ -171,6 +171,7 @@ export class SessionGuard): this { + setEmitter(emitter: EmitterLike): this { this.#emitter = emitter return this } @@ -265,6 +267,7 @@ export class SessionGuard { if (this.#emitter) { - this.#emitter.emit('session_auth:login_attempted', { user, guardName: this.#name }) + this.#emitter.emit('session_auth:login_attempted', { + ctx: this.#ctx, + user, + guardName: this.#name, + }) } const providerUser = await this.#userProvider.createUserForGuard(user) @@ -372,6 +379,7 @@ export class SessionGuard = { * have been verified successfully. */ 'session_auth:credentials_verified': { + ctx: HttpContext guardName: string uid: string user: User @@ -58,6 +60,7 @@ export type SessionGuardEvents = { * user. */ 'session_auth:login_failed': { + ctx: HttpContext guardName: string error: Exception user: User | null @@ -68,6 +71,7 @@ export type SessionGuardEvents = { * a given user. */ 'session_auth:login_attempted': { + ctx: HttpContext guardName: string user: User } @@ -77,6 +81,7 @@ export type SessionGuardEvents = { * successfully */ 'session_auth:login_succeeded': { + ctx: HttpContext guardName: string user: User sessionId: string @@ -87,6 +92,7 @@ export type SessionGuardEvents = { * Attempting to authenticate the user */ 'session_auth:authentication_attempted': { + ctx: HttpContext guardName: string sessionId: string } @@ -95,6 +101,7 @@ export type SessionGuardEvents = { * Authentication was successful */ 'session_auth:authentication_succeeded': { + ctx: HttpContext guardName: string user: User sessionId: string @@ -105,6 +112,7 @@ export type SessionGuardEvents = { * Authentication failed */ 'session_auth:authentication_failed': { + ctx: HttpContext guardName: string error: Exception sessionId: string @@ -115,6 +123,7 @@ export type SessionGuardEvents = { * sucessfully */ 'session_auth:logged_out': { + ctx: HttpContext guardName: string user: User | null sessionId: string diff --git a/tests/auth/auth_manager.spec.ts b/tests/auth/auth_manager.spec.ts index c2d01b1..41da2c7 100644 --- a/tests/auth/auth_manager.spec.ts +++ b/tests/auth/auth_manager.spec.ts @@ -19,7 +19,7 @@ test.group('Auth manager', () => { test('create authenticator from auth manager', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const authManager = new AuthManager({ default: 'web', diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index fa0b0ec..7afc924 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -20,7 +20,7 @@ test.group('Authenticator', () => { test('create authenticator with guards', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const authenticator = new Authenticator(ctx, { default: 'web', @@ -36,7 +36,7 @@ test.group('Authenticator', () => { test('access guard using its name', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const authenticator = new Authenticator(ctx, { default: 'web', @@ -60,7 +60,7 @@ test.group('Authenticator', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const authenticator = new Authenticator(ctx, { @@ -92,7 +92,7 @@ test.group('Authenticator', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const authenticator = new Authenticator(ctx, { diff --git a/tests/auth/authenticator_client.spec.ts b/tests/auth/authenticator_client.spec.ts index b28567c..49170ad 100644 --- a/tests/auth/authenticator_client.spec.ts +++ b/tests/auth/authenticator_client.spec.ts @@ -19,7 +19,7 @@ test.group('Authenticator client', () => { test('create authenticator client with guards', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const client = new AuthenticatorClient({ default: 'web', @@ -35,7 +35,7 @@ test.group('Authenticator client', () => { test('access guard using its name', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const client = new AuthenticatorClient({ default: 'web', @@ -60,7 +60,7 @@ test.group('Authenticator client', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const client = new AuthenticatorClient({ default: 'web', diff --git a/tests/guards/basic_auth/authenticate.spec.ts b/tests/guards/basic_auth/authenticate.spec.ts index 6075bb3..c0eda3c 100644 --- a/tests/guards/basic_auth/authenticate.spec.ts +++ b/tests/guards/basic_auth/authenticate.spec.ts @@ -25,7 +25,7 @@ test.group('BasicAuth guard | authenticate', () => { password: await new Scrypt({}).make('secret'), }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from( `${user.email}:secret` @@ -54,7 +54,7 @@ test.group('BasicAuth guard | authenticate', () => { password: await new Scrypt({}).make('secret'), }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from( `${user.email}:secret` @@ -80,7 +80,7 @@ test.group('BasicAuth guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) const [authFailed, authentication] = await Promise.allSettled([ pEvent(emitter, 'basic_auth:authentication_failed'), @@ -108,7 +108,7 @@ test.group('BasicAuth guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from(`foo:secret`).toString( 'base64' @@ -143,7 +143,7 @@ test.group('BasicAuth guard | authenticate', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from( `${user.email}:wrongpassword` @@ -175,7 +175,7 @@ test.group('BasicAuth guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) const [authFailed, authentication] = await Promise.allSettled([ pEvent(emitter, 'basic_auth:authentication_failed'), @@ -208,7 +208,7 @@ test.group('BasicAuth guard | authenticate', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from( `${user.email}:wrongpassword` @@ -244,7 +244,7 @@ test.group('BasicAuth guard | authenticate', () => { password: await new Scrypt({}).make('secret'), }) const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).withEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) await basicAuthGuard.authenticateAsClient(user) }).throws('Cannot authenticate as a client when using basic auth') }) diff --git a/tests/guards/session/attempt.spec.ts b/tests/guards/session/attempt.spec.ts index fc2e0f0..a19b19d 100644 --- a/tests/guards/session/attempt.spec.ts +++ b/tests/guards/session/attempt.spec.ts @@ -26,7 +26,7 @@ test.group('Session guard | attempt', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [credentialsVerified] = await Promise.all([ @@ -56,7 +56,7 @@ test.group('Session guard | attempt', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginFailed, attemptResult] = await Promise.allSettled([ @@ -79,7 +79,7 @@ test.group('Session guard | attempt', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginFailed, attemptResult] = await Promise.allSettled([ diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts index 523d543..04bdfcf 100644 --- a/tests/guards/session/authenticate.spec.ts +++ b/tests/guards/session/authenticate.spec.ts @@ -33,7 +33,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authSucceeded] = await Promise.all([ @@ -63,7 +63,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authFailed, authenticateCall] = await Promise.allSettled([ @@ -110,7 +110,7 @@ test.group('Session guard | authenticate', () => { const sessionGuard = new SessionGuardFactory() .create(ctx) .withRememberMeTokens(tokensProvider) - .withEmitter(emitter) + .setEmitter(emitter) const token = RememberMeToken.create(user.id, '1 year') await tokensProvider.createToken(token) @@ -331,7 +331,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authFailed, authenticateCall] = await Promise.allSettled([ @@ -352,7 +352,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authFailed, authenticateCall] = await Promise.allSettled([ @@ -378,7 +378,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) assert.deepEqual(await sessionGuard.authenticateAsClient(user), { session: { diff --git a/tests/guards/session/login.spec.ts b/tests/guards/session/login.spec.ts index 1eb101c..8cd87a7 100644 --- a/tests/guards/session/login.spec.ts +++ b/tests/guards/session/login.spec.ts @@ -45,7 +45,7 @@ test.group('Session guard | login', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginAttempted, loginSucceeded] = await Promise.all([ diff --git a/tests/guards/session/login_via_id.spec.ts b/tests/guards/session/login_via_id.spec.ts index b7028d0..4c0939a 100644 --- a/tests/guards/session/login_via_id.spec.ts +++ b/tests/guards/session/login_via_id.spec.ts @@ -26,7 +26,7 @@ test.group('Session guard | loginViaId', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await Promise.all([ @@ -50,7 +50,7 @@ test.group('Session guard | loginViaId', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginFailed, attemptResult] = await Promise.allSettled([ diff --git a/tests/guards/session/logout.spec.ts b/tests/guards/session/logout.spec.ts index 21e9836..177946a 100644 --- a/tests/guards/session/logout.spec.ts +++ b/tests/guards/session/logout.spec.ts @@ -102,7 +102,7 @@ test.group('Session guard | logout', () => { const ctx = new HttpContextFactory().create() const emitter = createEmitter() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { From c9398e356399744fe702238cab62b304ce6f3149 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 26 Dec 2023 13:40:30 +0530 Subject: [PATCH 49/96] ci: update node versions --- .github/workflows/checks.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 98b0520..8c26a40 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18.16.0, 20.x] + node-version: [20.10.0, 21.x] services: redis: image: redis @@ -44,7 +44,7 @@ jobs: runs-on: windows-latest strategy: matrix: - node-version: [18.16.0, 20.x] + node-version: [20.10.0, 21.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} From 3717a83b24b6d6053714b9e7aa2c122b6930dc1f Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 26 Dec 2023 13:46:10 +0530 Subject: [PATCH 50/96] chore(release): 9.0.0-10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b7ec5a..c7775ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adonisjs/auth", - "version": "9.0.0-9", + "version": "9.0.0-10", "description": "Official authentication provider for Adonis framework", "type": "module", "main": "build/index.js", From 743c79ad48cdda67964b290f7bd8aa65b69437a7 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 08:10:34 +0530 Subject: [PATCH 51/96] chore: update dependencies --- package.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index c7775ed..25a4057 100644 --- a/package.json +++ b/package.json @@ -67,30 +67,30 @@ "url": "https://github.com/adonisjs/auth/issues" }, "devDependencies": { - "@adonisjs/assembler": "^7.0.0-1", - "@adonisjs/core": "^6.1.5-36", - "@adonisjs/eslint-config": "^1.2.0", - "@adonisjs/i18n": "^2.0.0-9", - "@adonisjs/lucid": "^19.0.0-8", - "@adonisjs/prettier-config": "^1.2.0", - "@adonisjs/session": "^7.0.0-15", - "@adonisjs/tsconfig": "^1.2.0", - "@commitlint/cli": "^18.4.3", - "@commitlint/config-conventional": "^18.4.3", + "@adonisjs/assembler": "^7.0.0", + "@adonisjs/core": "^6.2.0", + "@adonisjs/eslint-config": "^1.2.1", + "@adonisjs/i18n": "^2.0.0", + "@adonisjs/lucid": "^19.0.0", + "@adonisjs/prettier-config": "^1.2.1", + "@adonisjs/session": "^7.0.0", + "@adonisjs/tsconfig": "^1.2.1", + "@commitlint/cli": "^18.4.4", + "@commitlint/config-conventional": "^18.4.4", "@japa/api-client": "^2.0.2", "@japa/assert": "^2.1.0", "@japa/browser-client": "^2.0.2", "@japa/expect-type": "^2.0.1", "@japa/file-system": "^2.1.1", - "@japa/plugin-adonisjs": "^2.0.1", + "@japa/plugin-adonisjs": "^3.0.0", "@japa/runner": "^3.1.1", "@japa/snapshot": "^2.0.4", - "@swc/core": "^1.3.101", - "@types/basic-auth": "^1.1.6", - "@types/luxon": "^3.3.7", - "@types/node": "^20.10.5", + "@swc/core": "^1.3.102", + "@types/basic-auth": "^1.1.7", + "@types/luxon": "^3.4.0", + "@types/node": "^20.10.8", "@types/set-cookie-parser": "^2.4.7", - "c8": "^8.0.1", + "c8": "^9.0.0", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", @@ -102,7 +102,7 @@ "playwright": "^1.40.1", "prettier": "^3.1.1", "set-cookie-parser": "^2.6.0", - "sqlite3": "^5.1.6", + "sqlite3": "^5.1.7", "timekeeper": "^2.3.1", "ts-node": "^10.9.2", "typescript": "^5.3.3" @@ -137,7 +137,7 @@ ] }, "dependencies": { - "@adonisjs/presets": "^2.1.0", + "@adonisjs/presets": "^2.1.1", "@poppinss/utils": "^6.7.0", "basic-auth": "^2.0.1" }, From e05bc463683f015b8e2319d7325bed8c5fdebdf7 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 09:14:50 +0530 Subject: [PATCH 52/96] feat: prevent timing attacks during verifyCredentials or attempt method call --- factories/lucid_user_provider.ts | 7 +- package.json | 1 + src/auth/define_config.ts | 11 ++- src/core/types.ts | 33 +++++-- src/core/user_providers/database.ts | 23 +++++ src/core/user_providers/lucid.ts | 57 ++++++++++- src/guards/basic_auth/guard.ts | 19 +--- src/guards/session/guard.ts | 23 ++--- src/guards/session/types.ts | 1 - tests/auth/define_config.spec.ts | 5 + .../database/verify_credentials.spec.ts | 94 +++++++++++++++++++ .../user_providers/lucid/guard_user.spec.ts | 16 ++++ .../lucid/verify_credentials.spec.ts | 91 ++++++++++++++++++ tests/guards/session/define_config.spec.ts | 3 + 14 files changed, 331 insertions(+), 53 deletions(-) create mode 100644 tests/core/user_providers/database/verify_credentials.spec.ts create mode 100644 tests/core/user_providers/lucid/verify_credentials.spec.ts diff --git a/factories/lucid_user_provider.ts b/factories/lucid_user_provider.ts index 3055473..5bba2b4 100644 --- a/factories/lucid_user_provider.ts +++ b/factories/lucid_user_provider.ts @@ -42,10 +42,6 @@ export class FactoryUser extends BaseModel { @column() declare password: string | null - - async verifyPasswordForAuth(plainTextPassword: string) { - return new Hash(new Scrypt({})).verify(this.password!, plainTextPassword) - } } export class TestLucidUserProvider< @@ -60,7 +56,7 @@ export class TestLucidUserProvider< */ export class LucidUserProviderFactory { createForModel(options: LucidUserProviderOptions) { - return new TestLucidUserProvider({ + return new TestLucidUserProvider(new Hash(new Scrypt({})), { ...options, }) } @@ -72,6 +68,7 @@ export class LucidUserProviderFactory { default: FactoryUser, } }, + passwordColumnName: 'password', uids: ['email', 'username'], }) } diff --git a/package.json b/package.json index 25a4057..effdb17 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@types/node": "^20.10.8", "@types/set-cookie-parser": "^2.4.7", "c8": "^9.0.0", + "convert-hrtime": "^5.0.0", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", diff --git a/src/auth/define_config.ts b/src/auth/define_config.ts index 62623a7..94166a5 100644 --- a/src/auth/define_config.ts +++ b/src/auth/define_config.ts @@ -82,13 +82,18 @@ export const providers: { const db = await app.container.make('lucid.db') const hasher = await app.container.make('hash') const { DatabaseUserProvider } = await import('./user_providers/main.js') - return new DatabaseUserProvider(db, hasher.use(), config) + return new DatabaseUserProvider( + db, + config.hasher ? hasher.use(config.hasher) : hasher.use(), + config + ) }) }, lucid(config) { - return configProvider.create(async () => { + return configProvider.create(async (app) => { const { LucidUserProvider } = await import('./user_providers/main.js') - return new LucidUserProvider(config) + const hasher = await app.container.make('hash') + return new LucidUserProvider(config.hasher ? hasher.use(config.hasher) : hasher.use(), config) }) }, } diff --git a/src/core/types.ts b/src/core/types.ts index 8982bf2..c54a04e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import type { HashersList } from '@adonisjs/core/types' import type { QueryClientContract } from '@adonisjs/lucid/types/database' import type { GuardUser } from './guard_user.js' @@ -94,6 +95,12 @@ export interface UserProviderContract { * authenticating user from their session. */ findById(value: string | number): Promise | null> + + /** + * Find a user by uid and verify their password. This method prevents + * timing attacks. + */ + verifyCredentials(uid: string | number, password: string): Promise | null> } /** @@ -126,19 +133,20 @@ export interface TokenProviderContract { * A lucid model that can be used during authentication */ export type LucidAuthenticatable = LucidModel & { - new (): LucidRow & { - /** - * Verify the plain text password against the user password - * hash - */ - verifyPasswordForAuth(plainTextPassword: string): Promise - } + // new (): LucidRow & {} + new (): LucidRow } /** * Options accepted by the Lucid user provider */ export type LucidUserProviderOptions = { + /** + * Define the hasher to use to hash and verify + * passwords + */ + hasher?: keyof HashersList + /** * Optionally define the connection to use when making database * queries @@ -159,6 +167,11 @@ export type LucidUserProviderOptions = { */ model: () => Promise<{ default: Model }> + /** + * Column name to read the hashed password + */ + passwordColumnName: Extract, string> + /** * An array of uids to use when finding a user for login. Make * sure all fields can be used to uniquely lookup a user. @@ -170,6 +183,12 @@ export type LucidUserProviderOptions = { * Options accepted by the Database user provider */ export type DatabaseUserProviderOptions> = { + /** + * Define the hasher to use to hash and verify + * passwords + */ + hasher?: keyof HashersList + /** * Optionally define the connection to use when making database * queries diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts index b2e7451..6c8c63b 100644 --- a/src/core/user_providers/database.ts +++ b/src/core/user_providers/database.ts @@ -149,4 +149,27 @@ export abstract class BaseDatabaseUserProvider | null> { + const user = await this.findByUid(uid) + if (user) { + if (await user.verifyPassword(password)) { + return user + } + return null + } + + /** + * Hashing the password to prevent timing attacks. + */ + await this.hasher.make(password) + return null + } } diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts index 6afc40f..87d8094 100644 --- a/src/core/user_providers/lucid.ts +++ b/src/core/user_providers/lucid.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import type { Hash } from '@adonisjs/core/hash' import { RuntimeException } from '@poppinss/utils' import debug from '../../auth/debug.js' @@ -23,6 +24,19 @@ import type { * to perform authentication. */ class LucidUser> extends GuardUser { + #options: { passwordColumnName: Extract } + #hasher: Hash + + constructor( + realUser: RealUser, + hasher: Hash, + options: { passwordColumnName: Extract } + ) { + super(realUser) + this.#hasher = hasher + this.#options = options + } + /** * @inheritdoc */ @@ -48,7 +62,14 @@ class LucidUser> extends Gua * @inheritdoc */ async verifyPassword(plainTextPassword: string): Promise { - return this.realUser.verifyPasswordForAuth(plainTextPassword) + const password = this.realUser[this.#options.passwordColumnName] + if (!password) { + throw new RuntimeException( + `Cannot verify password during login. The value of column "${this.#options.passwordColumnName}" is undefined or null` + ) + } + + return this.#hasher.verify(password as string, plainTextPassword) } } @@ -67,6 +88,11 @@ export abstract class BaseLucidUserProvider> | null> { + const user = await this.findByUid(uid) + if (user) { + if (await user.verifyPassword(password)) { + return user + } + return null + } + + /** + * Hashing the password to prevent timing attacks. + */ + await this.hasher.make(password) + return null } } diff --git a/src/guards/basic_auth/guard.ts b/src/guards/basic_auth/guard.ts index 17b84d7..a520929 100644 --- a/src/guards/basic_auth/guard.ts +++ b/src/guards/basic_auth/guard.ts @@ -129,27 +129,14 @@ export class BasicAuthGuard> debug('basic_auth_guard: attempting to verify credentials for uid "%s"', uid) /** - * Attempt to find a user by the uid and raise - * error when unable to find one + * Attempt to verify credentials and raise error if they are invalid */ - const providerUser = await this.#userProvider.findByUid(uid) + const providerUser = await this.#userProvider.verifyCredentials(uid, password) if (!providerUser) { this.#authenticationFailed(AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS()) } - /** - * Raise error when unable to verify password - */ - const user = providerUser.getOriginal() - - /** - * Raise error when unable to verify password - */ - if (!(await providerUser.verifyPassword(password))) { - this.#authenticationFailed(AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS()) - } - - return user + return providerUser.getOriginal() } /** diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts index 8fb489c..2f99210 100644 --- a/src/guards/session/guard.ts +++ b/src/guards/session/guard.ts @@ -184,13 +184,12 @@ export class SessionGuard = { ctx: HttpContext guardName: string error: Exception - user: User | null } /** diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts index b94402f..be41ddd 100644 --- a/tests/auth/define_config.spec.ts +++ b/tests/auth/define_config.spec.ts @@ -33,9 +33,12 @@ test.group('Define config | providers', () => { default: FactoryUser, } }, + passwordColumnName: 'password', uids: ['email'], }) + app.container.bind('hash', () => new HashManagerFactory().create()) + const lucidProvider = await lucidConfigProvider.resolver(app) assert.instanceOf(lucidProvider, LucidUserProvider) }) @@ -64,6 +67,7 @@ test.group('Define config', () => { default: FactoryUser, } }, + passwordColumnName: 'password', uids: ['email'], }) @@ -90,6 +94,7 @@ test.group('Define config', () => { default: FactoryUser, } }, + passwordColumnName: 'password', uids: ['email'], }) diff --git a/tests/core/user_providers/database/verify_credentials.spec.ts b/tests/core/user_providers/database/verify_credentials.spec.ts new file mode 100644 index 0000000..ffd6122 --- /dev/null +++ b/tests/core/user_providers/database/verify_credentials.spec.ts @@ -0,0 +1,94 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import convertHrtime from 'convert-hrtime' +import { FactoryUser } from '../../../../factories/lucid_user_provider.js' +import { createDatabase, createTables, getHasher } from '../../../helpers.js' +import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' + +test.group('Database user provider | verifyCredentials', () => { + test('return user when email and password are correct', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const userByEmail = await dbUserProvider.verifyCredentials('foo@bar.com', 'secret') + + expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf() + assert.equal(userByEmail!.getId(), 1) + assert.containsSubset(userByEmail!.getOriginal(), { + id: 1, + email: 'foo@bar.com', + username: 'foo', + }) + }) + + test('return null when password is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const userByEmail = await dbUserProvider.verifyCredentials('foo@bar.com', 'supersecret') + assert.isNull(userByEmail) + }) + + test('return null when email is incorrect', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const userByEmail = await dbUserProvider.verifyCredentials('bar@bar.com', 'secret') + assert.isNull(userByEmail) + }) + + test('prevent timing attacks when email or password are invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + let startTime = process.hrtime.bigint() + await dbUserProvider.verifyCredentials('baz@bar.com', 'secret') + const invalidEmailTime = convertHrtime(process.hrtime.bigint() - startTime) + + startTime = process.hrtime.bigint() + await dbUserProvider.verifyCredentials('foo@bar.com', 'supersecret') + const invalidPasswordTime = convertHrtime(process.hrtime.bigint() - startTime) + + /** + * Same timing within the range of 10 milliseconds is acceptable + */ + assert.isBelow(Math.abs(invalidPasswordTime.seconds - invalidEmailTime.seconds), 1) + assert.isBelow(Math.abs(invalidPasswordTime.milliseconds - invalidEmailTime.milliseconds), 10) + }) +}) diff --git a/tests/core/user_providers/lucid/guard_user.spec.ts b/tests/core/user_providers/lucid/guard_user.spec.ts index fb2c22f..d1adbd8 100644 --- a/tests/core/user_providers/lucid/guard_user.spec.ts +++ b/tests/core/user_providers/lucid/guard_user.spec.ts @@ -27,6 +27,22 @@ test.group('Lucid user provider | LucidUser', () => { assert.isFalse(await user!.verifyPassword('foobar')) }) + test('throw error when value of password column is missing', async () => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + password: null, + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const user = await lucidUserProvider.findByUid('foo@bar.com') + + await user!.verifyPassword('secret') + }).throws( + 'Cannot verify password during login. The value of column "password" is undefined or null' + ) + test('throw error when user primary key is missing', async () => { const db = await createDatabase() await createTables(db) diff --git a/tests/core/user_providers/lucid/verify_credentials.spec.ts b/tests/core/user_providers/lucid/verify_credentials.spec.ts new file mode 100644 index 0000000..a7a6a92 --- /dev/null +++ b/tests/core/user_providers/lucid/verify_credentials.spec.ts @@ -0,0 +1,91 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import convertHrtime from 'convert-hrtime' +import { createDatabase, createTables, getHasher } from '../../../helpers.js' +import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' + +test.group('Lucid user provider | verifyCredentials', () => { + test('return user when email and password are correct', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.create({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const userByEmail = await lucidUserProvider.verifyCredentials('foo@bar.com', 'secret') + + expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf>() + assert.instanceOf(userByEmail!.getOriginal(), FactoryUser) + assert.isFalse(userByEmail!.getOriginal().$isNew) + assert.equal(userByEmail!.getId(), 1) + }) + + test('return null when password is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.create({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const userByEmail = await lucidUserProvider.verifyCredentials('foo@bar.com', 'supersecret') + assert.isNull(userByEmail) + }) + + test('return null when email is incorrect', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.create({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const userByEmail = await lucidUserProvider.verifyCredentials('baz@bar.com', 'secret') + assert.isNull(userByEmail) + }) + + test('prevent timing attacks when email or password are invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.create({ + email: 'foo@bar.com', + username: 'foo', + password: await getHasher().make('secret'), + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + + let startTime = process.hrtime.bigint() + await lucidUserProvider.verifyCredentials('baz@bar.com', 'secret') + const invalidEmailTime = convertHrtime(process.hrtime.bigint() - startTime) + + startTime = process.hrtime.bigint() + await lucidUserProvider.verifyCredentials('foo@bar.com', 'supersecret') + const invalidPasswordTime = convertHrtime(process.hrtime.bigint() - startTime) + + /** + * Same timing within the range of 10 milliseconds is acceptable + */ + assert.isBelow(Math.abs(invalidPasswordTime.seconds - invalidEmailTime.seconds), 1) + assert.isBelow(Math.abs(invalidPasswordTime.milliseconds - invalidEmailTime.milliseconds), 10) + }) +}) diff --git a/tests/guards/session/define_config.spec.ts b/tests/guards/session/define_config.spec.ts index 7aa04ec..22dcc65 100644 --- a/tests/guards/session/define_config.spec.ts +++ b/tests/guards/session/define_config.spec.ts @@ -33,11 +33,13 @@ test.group('sessionGuard', () => { default: FactoryUser, } }, + passwordColumnName: 'password', uids: ['email'], }), }) app.container.bind('emitter', () => createEmitter() as any) + app.container.bind('hash', () => new HashManagerFactory().create()) const sessionFactory = await sessionGuardProvider.resolver('web', app) assert.isFunction(sessionFactory) @@ -61,6 +63,7 @@ test.group('sessionGuard', () => { default: FactoryUser, } }, + passwordColumnName: 'password', uids: ['email'], }), tokens: tokensProvider.db({ From 2c76eb8cc0f33ee7ace45c75321a1d44056892df Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 14:07:30 +0530 Subject: [PATCH 53/96] refactor: simplify providers part of core primitives --- factories/core/database_token_factory.ts | 41 ++++++ factories/core/database_user_provider.ts | 36 +++++ factories/core/lucid_user_provider.ts | 90 ++++++++++++ src/core/guard_user.ts | 6 - src/core/token.ts | 131 ------------------ src/core/token_providers/database.ts | 53 ++----- src/core/types.ts | 58 +------- src/core/user_providers/database.ts | 37 ++--- src/core/user_providers/lucid.ts | 42 ++---- tests/core/token.spec.ts | 59 -------- tests/core/token_providers/database.spec.ts | 76 +++------- .../database/create_user_for_guard.spec.ts | 25 +++- .../database/find_by_id.spec.ts | 4 +- .../database/find_by_uid.spec.ts | 4 +- .../database/guard_user.spec.ts | 56 -------- .../database/verify_credentials.spec.ts | 20 ++- .../lucid/create_user_for_guard.spec.ts | 26 +++- .../user_providers/lucid/find_by_id.spec.ts | 5 +- .../user_providers/lucid/find_by_uid.spec.ts | 5 +- .../user_providers/lucid/guard_user.spec.ts | 57 -------- .../lucid/verify_credentials.spec.ts | 21 ++- 21 files changed, 306 insertions(+), 546 deletions(-) create mode 100644 factories/core/database_token_factory.ts create mode 100644 factories/core/database_user_provider.ts create mode 100644 factories/core/lucid_user_provider.ts delete mode 100644 src/core/token.ts delete mode 100644 tests/core/token.spec.ts delete mode 100644 tests/core/user_providers/database/guard_user.spec.ts delete mode 100644 tests/core/user_providers/lucid/guard_user.spec.ts diff --git a/factories/core/database_token_factory.ts b/factories/core/database_token_factory.ts new file mode 100644 index 0000000..f6472d0 --- /dev/null +++ b/factories/core/database_token_factory.ts @@ -0,0 +1,41 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Database } from '@adonisjs/lucid/database' +import { DatabaseTokenProvider } from '../../src/core/token_providers/database.js' + +type TestToken = { + series: string + user_id: number + hash: string +} + +/** + * Test implementation of the database token provider + */ +export class TestDatabaseTokenProvider extends DatabaseTokenProvider { + protected prepareToken(dbRow: TestToken): TestToken { + return dbRow + } + + protected parseToken(token: TestToken): TestToken { + return token + } +} + +/** + * Creates instance of the TestDatabaseTokenProvider + */ +export class DatabaseTokenProviderFactory { + create(db: Database) { + return new TestDatabaseTokenProvider(db, { + table: 'test_tokens', + }) + } +} diff --git a/factories/core/database_user_provider.ts b/factories/core/database_user_provider.ts new file mode 100644 index 0000000..13347a6 --- /dev/null +++ b/factories/core/database_user_provider.ts @@ -0,0 +1,36 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Hash } from '@adonisjs/core/hash' +import type { Database } from '@adonisjs/lucid/database' +import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' +import { BaseDatabaseUserProvider } from '../../src/core/user_providers/database.js' + +/** + * Representation of a test database user provider extending + * the base abstract provider. + */ +export class TestDatabaseUserProvider< + RealUser extends Record, +> extends BaseDatabaseUserProvider {} + +/** + * Creates an instance of the DatabaseUserProvider with sane + * defaults for testing + */ +export class DatabaseUserProviderFactory { + create(db: Database) { + return new TestDatabaseUserProvider(db, new Hash(new Scrypt({})), { + id: 'id', + table: 'users', + passwordColumnName: 'password', + uids: ['email', 'username'], + }) + } +} diff --git a/factories/core/lucid_user_provider.ts b/factories/core/lucid_user_provider.ts new file mode 100644 index 0000000..4742ddc --- /dev/null +++ b/factories/core/lucid_user_provider.ts @@ -0,0 +1,90 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Hash } from '@adonisjs/core/hash' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' + +import { PROVIDER_REAL_USER } from '../../src/auth/symbols.js' +import { BaseLucidUserProvider } from '../../src/core/user_providers/lucid.js' +import type { LucidAuthenticatable, LucidUserProviderOptions } from '../../src/core/types.js' + +/** + * User model that writes to the users table. Used for testing + */ +export class FactoryUser extends BaseModel { + static table = 'users' + + static createWithDefaults(attributes?: { + email?: string + password?: string | null + username?: string + }) { + return this.create({ + email: 'foo@bar.com', + username: 'foo', + password: 'secret', + ...attributes, + }) + } + + @column() + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string | null +} + +/** + * User provider to read user data using the + * "FactoryUser" model + */ +export class TestLucidUserProvider< + UserModel extends LucidAuthenticatable, +> extends BaseLucidUserProvider { + declare [PROVIDER_REAL_USER]: InstanceType +} + +/** + * Creates an instance of the LucidUserProvider with sane + * defaults for testing + */ +export class LucidUserProviderFactory { + /** + * Creates instance of "TestLucidUserProvider" for a custom + * user model + */ + createForModel(options: LucidUserProviderOptions) { + return new TestLucidUserProvider(new Hash(new Scrypt({})), { + ...options, + }) + } + + /** + * Creates instance of "TestLucidUserProvider" for the "FactoryUser" + * model + */ + create() { + return this.createForModel({ + model: async () => { + return { + default: FactoryUser, + } + }, + passwordColumnName: 'password', + uids: ['email', 'username'], + }) + } +} diff --git a/src/core/guard_user.ts b/src/core/guard_user.ts index f92142a..c2917d7 100644 --- a/src/core/guard_user.ts +++ b/src/core/guard_user.ts @@ -22,12 +22,6 @@ export abstract class GuardUser { this.realUser = realUser } - /** - * Verifies the plain text password against the user password - * hash - */ - abstract verifyPassword(plainTextPassword: string): Promise - /** * Returns a value to uniquely identify the user. */ diff --git a/src/core/token.ts b/src/core/token.ts deleted file mode 100644 index 99f4b86..0000000 --- a/src/core/token.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { createHash } from 'node:crypto' -import string from '@adonisjs/core/helpers/string' -import { base64, safeEqual } from '@adonisjs/core/helpers' - -import type { TokenContract } from './types.js' - -/** - * A token represents an opaque token issued to a client - * to perform a specific task. - * - * The raw value of a token is only visible at the time of - * issuing it and one must persist hash to the database. - */ -export abstract class Token implements TokenContract { - /** - * Token type to uniquely identify a bucket of tokens - */ - abstract readonly type: string - - /** - * Arbitary meta-data associated with the token - */ - metaData?: Record - - /** - * Timestamp when the token will expire - */ - expiresAt?: Date - - /** - * Date/time when the token instance was created - */ - createdAt: Date = new Date() - - /** - * Date/time when the token was updated - */ - updatedAt: Date = new Date() - - constructor( - /** - * Series is a random number stored inside the database as it is - */ - public series: string, - - /** - * Value is a random number only available at the time of issuing - * the token. Afterwards, the value is undefined. - */ - public value: string | undefined, - - /** - * Hash reference to the token hash - */ - public hash: string - ) {} - - /** - * Define metadata for the token - */ - setMetaData(metaData: Record): this { - this.metaData = metaData - return this - } - - /** - * Verifies the value of a token against the pre-defined hash - */ - verify(value: string) { - const newHash = createHash('sha256').update(value).digest('hex') - return safeEqual(this.hash, newHash) - } - - /** - * Define the token expiresAt timestamp from a duration. The value - * value must be a number in seconds or a string expression. - */ - setExpiry(duration: string | number) { - /** - * Defining a date object and adding seconds since the - * creation of the token - */ - this.expiresAt = new Date() - this.expiresAt.setSeconds(this.createdAt.getSeconds() + string.seconds.parse(duration)) - } - - /** - * Creates token value, series, and hash - */ - static seed(size: number = 30) { - const series = string.random(15) - const value = string.random(size) - const hash = createHash('sha256').update(value).digest('hex') - - return { series, value: `${base64.urlEncode(series)}.${base64.urlEncode(value)}`, hash } - } - - /** - * Decodes a publicly shared token and return the series - * and the token value from it. - * - * Returns null when unable to decode the token because of - * invalid format or encoding. - */ - static decode(value: string): null | { series: string; value: string } { - const [series, ...tokenValue] = value.split('.') - if (!series || tokenValue.length === 0) { - return null - } - - const decodedSeries = base64.urlDecode(series) - const decodedValue = base64.urlDecode(tokenValue.join('.')) - if (!decodedSeries || !decodedValue) { - return null - } - - return { - series: decodedSeries, - value: decodedValue, - } - } -} diff --git a/src/core/token_providers/database.ts b/src/core/token_providers/database.ts index 7d2f5d2..a96117d 100644 --- a/src/core/token_providers/database.ts +++ b/src/core/token_providers/database.ts @@ -12,23 +12,12 @@ import type { Database } from '@adonisjs/lucid/database' import debug from '../../auth/debug.js' import type { DatabaseTokenProviderOptions, TokenProviderContract } from '../types.js' -/** - * The representation of a token inside the database - */ -type DatabaseTokenRow = { - series: string - user_id: string | number - type: string - token: string - created_at: Date - updated_at: Date - expires_at: Date | null -} & Record - /** * A generic implementation to read tokens from the database */ -export abstract class DatabaseTokenProvider implements TokenProviderContract { +export abstract class DatabaseTokenProvider, Token> + implements TokenProviderContract +{ constructor( /** * Reference to the database query builder needed to @@ -53,7 +42,7 @@ export abstract class DatabaseTokenProvider implements TokenProviderContr * Abstract method to prepare a token from the database * row */ - protected abstract prepareToken(dbRow: DatabaseTokenRow): Token + protected abstract prepareToken(dbRow: DatabaseTokenRow): Token | null /** * Returns an instance of the query builder @@ -99,29 +88,11 @@ export abstract class DatabaseTokenProvider implements TokenProviderContr .first() if (!token) { - debug('db_token_provider:: token %O', token) - return null - } - - if (typeof token.expires_at === 'number') { - token.expires_at = new Date(token.expires_at) - } - if (typeof token.created_at === 'number') { - token.created_at = new Date(token.created_at) - } - if (typeof token.updated_at === 'number') { - token.updated_at = new Date(token.updated_at) - } - - debug('db_token_provider:: token %O', token) - - /** - * Return null when token has been expired - */ - if (token.expires_at && token.expires_at instanceof Date && token.expires_at < new Date()) { + debug('db_token_provider: cannot find token for series %s', series) return null } + debug('db_token_provider: token found %O', token) return this.prepareToken(token) } @@ -137,18 +108,14 @@ export abstract class DatabaseTokenProvider implements TokenProviderContr /** * Updates token hash and expiry */ - async updateTokenBySeries(series: string, hash: string, expiresAt: Date): Promise { - const updatePayload = { - token: hash, - updated_at: new Date(), - expires_at: expiresAt, - } + async updateTokenBySeries(series: string, token: Token): Promise { + const parsedToken = this.parseToken(token) - debug('db_token_provider: updating token by series %s: %O', series, updatePayload) + debug('db_token_provider: updating token by series %s: %O', series, parsedToken) await this.getQueryBuilder() .from(this.options.table) .where('series', series) - .update(updatePayload) + .update({ ...parsedToken }) } } diff --git a/src/core/types.ts b/src/core/types.ts index c54a04e..46b5f1c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -14,62 +14,6 @@ import type { GuardUser } from './guard_user.js' import type { PROVIDER_REAL_USER } from '../auth/symbols.js' import type { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' -/** - * A token represents an opaque token issued to a client - * to perform a specific task. - * - * The raw value of a token is only visible at the time of - * issuing it and one must persist hash to the database. - */ -export interface TokenContract { - /** - * Token type to uniquely identify a bucket of tokens - */ - readonly type: string - - /** - * The plain text value. Only exists when the token is first - * created - */ - value?: string - - /** - * Additional metadata associated with the token. - */ - metaData?: Record - - /** - * The token hash for persisting the token in a database - */ - hash: string - - /** - * A unique readable series counter to find the token inside the - * database. - */ - series: string - - /** - * Timestamp when the token was first persisted - */ - createdAt: Date - - /** - * Timestamp when the token was updated - */ - updatedAt: Date - - /** - * Timestamp when the token will expire - */ - expiresAt?: Date - - /** - * Verifies the raw text value against the hash - */ - verify(value: string): boolean -} - /** * The UserProvider is used to lookup a user for authentication */ @@ -121,7 +65,7 @@ export interface TokenProviderContract { /** * Updates a token by the series counter */ - updateTokenBySeries(series: string, hash: string, expiresAt: Date): Promise + updateTokenBySeries(series: string, token: Token): Promise /** * Creates a new token and persists it to the database diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts index 6c8c63b..a562e33 100644 --- a/src/core/user_providers/database.ts +++ b/src/core/user_providers/database.ts @@ -21,16 +21,10 @@ import type { DatabaseUserProviderOptions, UserProviderContract } from '../types * to perform authentication. */ class DatabaseUser> extends GuardUser { - #options: { id: string; passwordColumnName: string } - #hasher: Hash + #options: { id: string } - constructor( - realUser: RealUser, - hasher: Hash, - options: { id: string; passwordColumnName: string } - ) { + constructor(realUser: RealUser, options: { id: string }) { super(realUser) - this.#hasher = hasher this.#options = options } @@ -48,21 +42,6 @@ class DatabaseUser> extends GuardUser { - const password = this.realUser[this.#options.passwordColumnName] - - if (!password) { - throw new RuntimeException( - `Cannot verify password during login. The value of column "${this.#options.passwordColumnName}" is undefined or null` - ) - } - - return this.#hasher.verify(password, plainTextPassword) - } } /** @@ -113,7 +92,7 @@ export abstract class BaseDatabaseUserProvider | null> { const user = await this.findByUid(uid) + if (user) { - if (await user.verifyPassword(password)) { + const passwordHash = user.getOriginal()[this.options.passwordColumnName] + if (!passwordHash) { + throw new RuntimeException( + `Cannot verify password during login. The value of column "${this.options.passwordColumnName}" is undefined or null` + ) + } + + if (await this.hasher.verify(passwordHash, password)) { return user } return null diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts index 87d8094..53068a6 100644 --- a/src/core/user_providers/lucid.ts +++ b/src/core/user_providers/lucid.ts @@ -24,19 +24,6 @@ import type { * to perform authentication. */ class LucidUser> extends GuardUser { - #options: { passwordColumnName: Extract } - #hasher: Hash - - constructor( - realUser: RealUser, - hasher: Hash, - options: { passwordColumnName: Extract } - ) { - super(realUser) - this.#hasher = hasher - this.#options = options - } - /** * @inheritdoc */ @@ -57,20 +44,6 @@ class LucidUser> extends Gua return id } - - /** - * @inheritdoc - */ - async verifyPassword(plainTextPassword: string): Promise { - const password = this.realUser[this.#options.passwordColumnName] - if (!password) { - throw new RuntimeException( - `Cannot verify password during login. The value of column "${this.#options.passwordColumnName}" is undefined or null` - ) - } - - return this.#hasher.verify(password as string, plainTextPassword) - } } /** @@ -139,7 +112,7 @@ export abstract class BaseLucidUserProvider> | null> { const user = await this.findByUid(uid) if (user) { - if (await user.verifyPassword(password)) { + const passwordHash = user.getOriginal()[this.options.passwordColumnName] + if (!passwordHash) { + throw new RuntimeException( + `Cannot verify password during login. The value of column "${this.options.passwordColumnName}" is undefined or null` + ) + } + + if (await this.hasher.verify(passwordHash as string, password)) { return user } return null diff --git a/tests/core/token.spec.ts b/tests/core/token.spec.ts deleted file mode 100644 index 611bf9f..0000000 --- a/tests/core/token.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { TestToken } from '../../factories/database_token_factory.js' - -test.group('Token', () => { - test('create a token', ({ assert }) => { - const token = new TestToken('1234', 'random-string', 'random-string-hash') - assert.equal(token.series, '1234') - assert.equal(token.value, 'random-string') - assert.equal(token.hash, 'random-string-hash') - assert.isDefined(token.createdAt) - assert.isUndefined(token.expiresAt) - assert.isUndefined(token.metaData) - assert.equal(token.type, 'test_token') - }) - - test('create a token with seeded values', ({ assert }) => { - const { series, value, hash } = TestToken.seed() - const token = new TestToken(series, value, hash) - assert.equal(token.series, series) - assert.equal(token.value, value) - assert.equal(token.hash, hash) - assert.isDefined(token.createdAt) - assert.isUndefined(token.expiresAt) - assert.isUndefined(token.metaData) - assert.equal(token.type, 'test_token') - }) - - test('verify value against the hash', ({ assert }) => { - const { series, value, hash } = TestToken.seed() - const token = new TestToken(series, value, hash) - - assert.isTrue(token.verify(TestToken.decode(value)!.value)) - }) - - test('set token metadata', ({ assert }) => { - const { series, value, hash } = TestToken.seed() - const token = new TestToken(series, value, hash) - token.setMetaData({ permissions: ['read-file', 'write-users'] }) - assert.deepEqual(token.metaData, { permissions: ['read-file', 'write-users'] }) - }) - - test('decode valid and invalid tokens', ({ assert }) => { - assert.isNull(TestToken.decode('foo')) - assert.isNull(TestToken.decode('foo.bar')) - - const { series, value } = TestToken.seed() - const decoded = TestToken.decode(value)! - assert.equal(series, decoded.series) - }) -}) diff --git a/tests/core/token_providers/database.spec.ts b/tests/core/token_providers/database.spec.ts index 99911f4..453f7dc 100644 --- a/tests/core/token_providers/database.spec.ts +++ b/tests/core/token_providers/database.spec.ts @@ -8,54 +8,37 @@ */ import { test } from '@japa/runner' -import { createDatabase, createTables, timeTravel } from '../../helpers.js' -import { DatabaseTokenProviderFactory, TestToken } from '../../../factories/main.js' +import { createDatabase, createTables } from '../../helpers.js' +import { DatabaseTokenProviderFactory } from '../../../factories/core/database_token_factory.js' test.group('Database token provider | createToken', () => { test('persist a token to the database', async ({ assert }) => { const db = await createDatabase() await createTables(db) - const token = TestToken.create(1, '10mins') + const token = { series: '12345', hash: '12345_hash', user_id: 1 } const databaseProvider = new DatabaseTokenProviderFactory().create(db) await databaseProvider.createToken(token) - const tokens = await db.query().from('remember_me_tokens') + const tokens = await db.query().from('test_tokens') assert.lengthOf(tokens, 1) - assert.equal(tokens[0].user_id, 1) + assert.equal(tokens[0].user_id, token.user_id) + assert.equal(tokens[0].hash, token.hash) assert.equal(tokens[0].series, token.series) - assert.exists(tokens[0].created_at) - assert.exists(tokens[0].updated_at) - assert.isAbove(tokens[0].expires_at, tokens[0].created_at) - - /** - * Creating a fresh token from the database entry - */ - const freshToken = new TestToken(tokens[0].series, undefined, tokens[0].token) - - /** - * Verifying the token public value matches the saved hash - */ - const { value } = TestToken.decode(token.value!)! - assert.isTrue(freshToken.verify(value)) }) test('find token by series', async ({ assert }) => { const db = await createDatabase() await createTables(db) - const token = TestToken.create(1, '10mins') + const token = { series: '12345', hash: '12345_hash', user_id: 1 } const databaseProvider = new DatabaseTokenProviderFactory().create(db) await databaseProvider.createToken(token) const freshToken = await databaseProvider.getTokenBySeries(token.series) - /** - * Verifying the token public value matches the saved hash - */ - const { value } = TestToken.decode(token.value!)! - assert.isTrue(freshToken!.verify(value)) + assert.deepEqual(freshToken, token) }) test("return null when token doesn't exists", async ({ assert }) => { @@ -63,57 +46,30 @@ test.group('Database token provider | createToken', () => { await createTables(db) const databaseProvider = new DatabaseTokenProviderFactory().create(db) - assert.isNull(await databaseProvider.getTokenBySeries('foobar')) }) - test('return null when token is expired', async ({ assert }) => { + test('update token by series', async ({ assert }) => { const db = await createDatabase() await createTables(db) - const token = TestToken.create(1, '2 sec') + const token = { series: '12345', hash: '12345_hash', user_id: 1 } const databaseProvider = new DatabaseTokenProviderFactory().create(db) await databaseProvider.createToken(token) - timeTravel(3) + token.hash = '12345_hash_updated' + await databaseProvider.updateTokenBySeries(token.series, token) - assert.isNull(await databaseProvider.getTokenBySeries(token.series)) + const tokens = await db.query().from('test_tokens') + assert.lengthOf(tokens, 1) + assert.equal(tokens[0].hash, '12345_hash_updated') }) - test('update token hash and expiry', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = TestToken.create(1, '2sec') - const databaseProvider = new DatabaseTokenProviderFactory().create(db) - - await databaseProvider.createToken(token) - - /** - * Wait for the token expire - */ - timeTravel(3) - assert.isNull(await databaseProvider.getTokenBySeries(token.series)) - - /** - * Update token expiry - */ - const dateInFuture = new Date() - dateInFuture.setSeconds(dateInFuture.getSeconds() * 60) - await databaseProvider.updateTokenBySeries(token.series, token.hash, dateInFuture) - - /** - * Ensure it has been set properly - */ - const freshToken = await databaseProvider.getTokenBySeries(token.series) - assert.isTrue(freshToken!.expiresAt! > new Date()) - }).timeout(4000) - test('delete token by series', async ({ assert }) => { const db = await createDatabase() await createTables(db) - const token = TestToken.create(1, '10mins') + const token = { series: '12345', hash: '12345_hash', user_id: 1 } const databaseProvider = new DatabaseTokenProviderFactory().create(db) await databaseProvider.createToken(token) diff --git a/tests/core/user_providers/database/create_user_for_guard.spec.ts b/tests/core/user_providers/database/create_user_for_guard.spec.ts index 07b071a..e731909 100644 --- a/tests/core/user_providers/database/create_user_for_guard.spec.ts +++ b/tests/core/user_providers/database/create_user_for_guard.spec.ts @@ -9,8 +9,8 @@ import { test } from '@japa/runner' import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser } from '../../../../factories/lucid_user_provider.js' -import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' +import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' test.group('Database user provider | createUserForGuard', () => { test('create a guard user from database row', async ({ assert, expectTypeOf }) => { @@ -21,11 +21,11 @@ test.group('Database user provider | createUserForGuard', () => { const user = await db.connection().from('users').where('id', id).first() const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const userByRow = await dbUserProvider.createUserForGuard(user) + const providerUser = await dbUserProvider.createUserForGuard(user) - expectTypeOf(userByRow!.getOriginal()).toMatchTypeOf() - assert.equal(userByRow!.getId(), 1) - assert.deepEqual(userByRow!.getOriginal(), { + expectTypeOf(providerUser.getOriginal()).toMatchTypeOf() + assert.equal(providerUser.getId(), 1) + assert.deepEqual(providerUser.getOriginal(), { id: 1, email: 'foo@bar.com', username: 'foo', @@ -40,4 +40,17 @@ test.group('Database user provider | createUserForGuard', () => { const dbUserProvider = new DatabaseUserProviderFactory().create(db) await dbUserProvider.createUserForGuard(null as any) }).throws('Invalid user object. It must be a database row object from the "users" table') + + test('return error when value primaryColumn is missing', async () => { + const db = await createDatabase() + await createTables(db) + + const { id } = await FactoryUser.createWithDefaults() + const user = await db.connection().from('users').where('id', id).first() + delete user.id + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + const providerUser = await dbUserProvider.createUserForGuard(user) + providerUser.getId() + }).throws('Invalid user object. The value of column "id" is undefined or null') }) diff --git a/tests/core/user_providers/database/find_by_id.spec.ts b/tests/core/user_providers/database/find_by_id.spec.ts index 81e2da1..5ce01fb 100644 --- a/tests/core/user_providers/database/find_by_id.spec.ts +++ b/tests/core/user_providers/database/find_by_id.spec.ts @@ -9,8 +9,8 @@ import { test } from '@japa/runner' import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser } from '../../../../factories/lucid_user_provider.js' -import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' +import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' test.group('Database user provider | findById', () => { test('find a user using primary key', async ({ assert, expectTypeOf }) => { diff --git a/tests/core/user_providers/database/find_by_uid.spec.ts b/tests/core/user_providers/database/find_by_uid.spec.ts index b5d5155..1ba3d04 100644 --- a/tests/core/user_providers/database/find_by_uid.spec.ts +++ b/tests/core/user_providers/database/find_by_uid.spec.ts @@ -9,8 +9,8 @@ import { test } from '@japa/runner' import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser } from '../../../../factories/lucid_user_provider.js' -import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' +import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' test.group('Database user provider | findByUId', () => { test('find a user using primary key', async ({ assert, expectTypeOf }) => { diff --git a/tests/core/user_providers/database/guard_user.spec.ts b/tests/core/user_providers/database/guard_user.spec.ts deleted file mode 100644 index 96b6c77..0000000 --- a/tests/core/user_providers/database/guard_user.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables, getHasher } from '../../../helpers.js' -import { FactoryUser } from '../../../../factories/lucid_user_provider.js' -import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' - -test.group('Database user provider | createUserForGuard', () => { - test('verify user password using guard user instance', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - password: await getHasher().make('secret'), - }) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const user = await dbUserProvider.findByUid('foo@bar.com') - - assert.isTrue(await user!.verifyPassword('secret')) - assert.isFalse(await user!.verifyPassword('foobar')) - }) - - test('throw error when value of password column is missing', async () => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - password: null, - }) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const user = await dbUserProvider.findByUid('foo@bar.com') - - await user!.verifyPassword('secret') - }).throws( - 'Cannot verify password during login. The value of column "password" is undefined or null' - ) - - test('throw error when value of id column is missing', async () => { - const db = await createDatabase() - await createTables(db) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const user = await dbUserProvider.createUserForGuard({ email: 'foo@bar.com', username: 'foo' }) - - user!.getId() - }).throws('Invalid user object. The value of column "id" is undefined or null') -}) diff --git a/tests/core/user_providers/database/verify_credentials.spec.ts b/tests/core/user_providers/database/verify_credentials.spec.ts index ffd6122..cdb55f9 100644 --- a/tests/core/user_providers/database/verify_credentials.spec.ts +++ b/tests/core/user_providers/database/verify_credentials.spec.ts @@ -9,9 +9,9 @@ import { test } from '@japa/runner' import convertHrtime from 'convert-hrtime' -import { FactoryUser } from '../../../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' import { createDatabase, createTables, getHasher } from '../../../helpers.js' -import { DatabaseUserProviderFactory } from '../../../../factories/database_user_provider.js' +import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' test.group('Database user provider | verifyCredentials', () => { test('return user when email and password are correct', async ({ assert, expectTypeOf }) => { @@ -66,6 +66,22 @@ test.group('Database user provider | verifyCredentials', () => { assert.isNull(userByEmail) }) + test('throw error when password is missing', async () => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.createWithDefaults({ + email: 'foo@bar.com', + username: 'foo', + password: null, + }) + + const dbUserProvider = new DatabaseUserProviderFactory().create(db) + await dbUserProvider.verifyCredentials('foo@bar.com', 'secret') + }).throws( + 'Cannot verify password during login. The value of column "password" is undefined or null' + ) + test('prevent timing attacks when email or password are invalid', async ({ assert }) => { const db = await createDatabase() await createTables(db) diff --git a/tests/core/user_providers/lucid/create_user_for_guard.spec.ts b/tests/core/user_providers/lucid/create_user_for_guard.spec.ts index 29939e9..a39e163 100644 --- a/tests/core/user_providers/lucid/create_user_for_guard.spec.ts +++ b/tests/core/user_providers/lucid/create_user_for_guard.spec.ts @@ -9,7 +9,10 @@ import { test } from '@japa/runner' import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' +import { + FactoryUser, + LucidUserProviderFactory, +} from '../../../../factories/core/lucid_user_provider.js' test.group('Lucid user provider | createUserForGuard', () => { test('create a guard user from a model instance', async ({ assert, expectTypeOf }) => { @@ -23,12 +26,12 @@ test.group('Lucid user provider | createUserForGuard', () => { }) const lucidUserProvider = new LucidUserProviderFactory().create() - const userByInstance = await lucidUserProvider.createUserForGuard(user) + const providerUser = await lucidUserProvider.createUserForGuard(user) - expectTypeOf(userByInstance!.getOriginal()).toMatchTypeOf>() - assert.instanceOf(userByInstance!.getOriginal(), FactoryUser) - assert.isFalse(userByInstance!.getOriginal().$isNew) - assert.equal(userByInstance!.getId(), 1) + expectTypeOf(providerUser.getOriginal()).toMatchTypeOf>() + assert.instanceOf(providerUser.getOriginal(), FactoryUser) + assert.isFalse(providerUser.getOriginal().$isNew) + assert.equal(providerUser.getId(), 1) }) test('return error when user is not an instance of Model', async () => { @@ -38,4 +41,15 @@ test.group('Lucid user provider | createUserForGuard', () => { const lucidUserProvider = new LucidUserProviderFactory().create() await lucidUserProvider.createUserForGuard({} as any) }).throws('Invalid user object. It must be an instance of the "FactoryUser" model') + + test('return error when user primary key is missing', async () => { + const db = await createDatabase() + await createTables(db) + + const lucidUserProvider = new LucidUserProviderFactory().create() + const user = await lucidUserProvider.createUserForGuard(new FactoryUser()) + user.getId() + }).throws( + 'Cannot use "FactoryUser" model for authentication. The value of column "id" is undefined or null' + ) }) diff --git a/tests/core/user_providers/lucid/find_by_id.spec.ts b/tests/core/user_providers/lucid/find_by_id.spec.ts index 88acfbc..7d99181 100644 --- a/tests/core/user_providers/lucid/find_by_id.spec.ts +++ b/tests/core/user_providers/lucid/find_by_id.spec.ts @@ -9,7 +9,10 @@ import { test } from '@japa/runner' import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' +import { + FactoryUser, + LucidUserProviderFactory, +} from '../../../../factories/core/lucid_user_provider.js' test.group('Lucid user provider | findById', () => { test('find a user using primary key', async ({ assert, expectTypeOf }) => { diff --git a/tests/core/user_providers/lucid/find_by_uid.spec.ts b/tests/core/user_providers/lucid/find_by_uid.spec.ts index 4871c45..dce1279 100644 --- a/tests/core/user_providers/lucid/find_by_uid.spec.ts +++ b/tests/core/user_providers/lucid/find_by_uid.spec.ts @@ -9,7 +9,10 @@ import { test } from '@japa/runner' import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' +import { + FactoryUser, + LucidUserProviderFactory, +} from '../../../../factories/core/lucid_user_provider.js' test.group('Lucid user provider | findByUid', () => { test('find a user for login using uids', async ({ assert, expectTypeOf }) => { diff --git a/tests/core/user_providers/lucid/guard_user.spec.ts b/tests/core/user_providers/lucid/guard_user.spec.ts deleted file mode 100644 index d1adbd8..0000000 --- a/tests/core/user_providers/lucid/guard_user.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables, getHasher } from '../../../helpers.js' -import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' - -test.group('Lucid user provider | LucidUser', () => { - test('verify user password using guard user instance', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - password: await getHasher().make('secret'), - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - - const user = await lucidUserProvider.findByUid('foo@bar.com') - assert.isTrue(await user!.verifyPassword('secret')) - assert.isFalse(await user!.verifyPassword('foobar')) - }) - - test('throw error when value of password column is missing', async () => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - password: null, - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const user = await lucidUserProvider.findByUid('foo@bar.com') - - await user!.verifyPassword('secret') - }).throws( - 'Cannot verify password during login. The value of column "password" is undefined or null' - ) - - test('throw error when user primary key is missing', async () => { - const db = await createDatabase() - await createTables(db) - - const lucidUserProvider = new LucidUserProviderFactory().create() - - const user = await lucidUserProvider.createUserForGuard(new FactoryUser()) - user.getId() - }).throws( - 'Cannot use "FactoryUser" model for authentication. The value of column "id" is undefined or null' - ) -}) diff --git a/tests/core/user_providers/lucid/verify_credentials.spec.ts b/tests/core/user_providers/lucid/verify_credentials.spec.ts index a7a6a92..ccd2858 100644 --- a/tests/core/user_providers/lucid/verify_credentials.spec.ts +++ b/tests/core/user_providers/lucid/verify_credentials.spec.ts @@ -10,7 +10,10 @@ import { test } from '@japa/runner' import convertHrtime from 'convert-hrtime' import { createDatabase, createTables, getHasher } from '../../../helpers.js' -import { FactoryUser, LucidUserProviderFactory } from '../../../../factories/lucid_user_provider.js' +import { + FactoryUser, + LucidUserProviderFactory, +} from '../../../../factories/core/lucid_user_provider.js' test.group('Lucid user provider | verifyCredentials', () => { test('return user when email and password are correct', async ({ assert, expectTypeOf }) => { @@ -62,6 +65,22 @@ test.group('Lucid user provider | verifyCredentials', () => { assert.isNull(userByEmail) }) + test('throw error when password is missing', async () => { + const db = await createDatabase() + await createTables(db) + + await FactoryUser.create({ + email: 'foo@bar.com', + username: 'foo', + password: null, + }) + + const lucidUserProvider = new LucidUserProviderFactory().create() + await lucidUserProvider.verifyCredentials('foo@bar.com', 'secret') + }).throws( + 'Cannot verify password during login. The value of column "password" is undefined or null' + ) + test('prevent timing attacks when email or password are invalid', async ({ assert }) => { const db = await createDatabase() await createTables(db) From b2a8f48a96a9bbcd4e60f9e5eebb04266fcb4666 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 15:21:52 +0530 Subject: [PATCH 54/96] refactor: cleanup session guard implementation --- .../database_remember_token_factory.ts | 22 +++ src/guards/session/define_config.ts | 12 +- src/guards/session/guard.ts | 183 ++++++++---------- src/guards/session/main.ts | 2 +- src/guards/session/remember_me_token.ts | 164 ++++++++++++++++ src/guards/session/token.ts | 64 ------ .../session/token_providers/database.ts | 83 ++++++++ src/guards/session/token_providers/main.ts | 66 ------- src/guards/session/types.ts | 2 +- tests/guards/session/attempt.spec.ts | 16 +- tests/guards/session/authenticate.spec.ts | 161 +++++++++++---- tests/guards/session/define_config.spec.ts | 2 +- tests/guards/session/get_user.spec.ts | 10 +- tests/guards/session/login.spec.ts | 22 ++- tests/guards/session/login_via_id.spec.ts | 6 +- tests/guards/session/logout.spec.ts | 80 ++++---- .../session/remember_me_db_provider.spec.ts | 86 ++++++++ .../guards/session/remember_me_token.spec.ts | 111 +++++++++++ 18 files changed, 754 insertions(+), 338 deletions(-) create mode 100644 factories/guards/session/database_remember_token_factory.ts create mode 100644 src/guards/session/remember_me_token.ts delete mode 100644 src/guards/session/token.ts create mode 100644 src/guards/session/token_providers/database.ts delete mode 100644 src/guards/session/token_providers/main.ts create mode 100644 tests/guards/session/remember_me_db_provider.spec.ts create mode 100644 tests/guards/session/remember_me_token.spec.ts diff --git a/factories/guards/session/database_remember_token_factory.ts b/factories/guards/session/database_remember_token_factory.ts new file mode 100644 index 0000000..44b1594 --- /dev/null +++ b/factories/guards/session/database_remember_token_factory.ts @@ -0,0 +1,22 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Database } from '@adonisjs/lucid/database' +import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' + +/** + * Creates instance of the DatabaseRememberTokenProvider + */ +export class DatabaseRememberTokenFactory { + create(db: Database) { + return new DatabaseRememberTokenProvider(db, { + table: 'remember_me_tokens', + }) + } +} diff --git a/src/guards/session/define_config.ts b/src/guards/session/define_config.ts index d1a9eff..384d509 100644 --- a/src/guards/session/define_config.ts +++ b/src/guards/session/define_config.ts @@ -52,12 +52,18 @@ export function sessionGuard { - const guard = new SessionGuard(guardName, config, ctx, provider) + const guard = new SessionGuard( + guardName, + config, + ctx, + emitter as any, + provider + ) if (tokensProvider) { guard.withRememberMeTokens(tokensProvider) } - return guard.setEmitter(emitter) + return guard } }, } @@ -72,7 +78,7 @@ export const tokensProvider: { db(config) { return configProvider.create(async (app) => { const db = await app.container.make('lucid.db') - const { DatabaseRememberTokenProvider } = await import('./token_providers/main.js') + const { DatabaseRememberTokenProvider } = await import('./token_providers/database.js') return new DatabaseRememberTokenProvider(db, config) }) }, diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts index 2f99210..78b10ab 100644 --- a/src/guards/session/guard.ts +++ b/src/guards/session/guard.ts @@ -14,7 +14,7 @@ import { Exception, RuntimeException } from '@poppinss/utils' import type { EmitterLike } from '@adonisjs/core/types/events' import debug from '../../auth/debug.js' -import { RememberMeToken } from './token.js' +import { RememberMeToken } from './remember_me_token.js' import type { GuardContract } from '../../auth/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../auth/symbols.js' import { AuthenticationException, InvalidCredentialsException } from '../../auth/errors.js' @@ -64,7 +64,7 @@ export class SessionGuard> + #emitter: EmitterLike> /** * Driver name of the guard @@ -129,11 +129,13 @@ export class SessionGuard>, userProvider: UserProvider ) { this.#name = name this.#ctx = ctx this.#config = config + this.#emitter = emitter this.#userProvider = userProvider } @@ -169,14 +171,12 @@ export class SessionGuard): this { - this.#emitter = emitter - return this - } - /** * Returns an instance of the authenticated user. Or throws * an exception if the request is not authenticated. @@ -254,14 +243,12 @@ export class SessionGuard { - if (this.#emitter) { - this.#emitter.emit('session_auth:login_attempted', { - ctx: this.#ctx, - user, - guardName: this.#name, - }) - } + this.#emitter.emit('session_auth:login_attempted', { + ctx: this.#ctx, + user, + guardName: this.#name, + }) const providerUser = await this.#userProvider.createUserForGuard(user) const session = this.#getSession() + const userId = providerUser.getId() /** * Create session and recycle the session id */ - const userId = providerUser.getId() - debug('session_guard: marking user with id "%s" as logged-in', userId) session.put(this.sessionKeyName, userId) session.regenerate() @@ -331,14 +315,12 @@ export class SessionGuard + + /** + * Date/time when the token instance was created + */ + declare createdAt: Date + + /** + * Date/time when the token was updated + */ + declare updatedAt: Date + + /** + * Hash is computed from the seed to later verify the validify + * of seed + */ + declare hash: string + + /** + * Timestamp at which the token will expire + */ + declare expiresAt: Date + + constructor( + /** + * Reference to the user id for whom the token + * is generated + */ + public userId: string | number, + + /** + * Guard for which the token is generated. This is to avoid + * cross guards using each others remember me tokens + */ + public guard: string, + + /** + * Series is a unique sequence to identify the + * token within database. It should be the + * primary/unique key + */ + public series: string + ) {} + + /** + * Refreshes the token's value, hash, updatedAt and + * expiresAt timestamps + */ + refresh(expiry: string | number, size: number = 30) { + const seed = string.random(size) + + /** + * Re-computing public value and hash + */ + this.hash = createHash('sha256').update(seed).digest('hex') + this.value = new Secret(`${base64.urlEncode(this.series)}.${base64.urlEncode(seed)}`) + + /** + * Updating expiry and updated_at timestamp + */ + this.updatedAt = new Date() + this.expiresAt = new Date() + this.expiresAt.setSeconds(this.updatedAt.getSeconds() + string.seconds.parse(expiry)) + } + + /** + * Verifies the value of a token against the pre-defined hash + */ + verify(value: string): boolean { + const newHash = createHash('sha256').update(value).digest('hex') + return safeEqual(this.hash, newHash) + } +} diff --git a/src/guards/session/token.ts b/src/guards/session/token.ts deleted file mode 100644 index 80af118..0000000 --- a/src/guards/session/token.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Token } from '../../core/token.js' - -/** - * Remember me token represents a remember me token created - * for a peristed login flow. - */ -export class RememberMeToken extends Token { - /** - * Static name for the token to uniquely identify a - * bucket of tokens - */ - readonly type: 'remember_me_token' = 'remember_me_token' - - /** - * Timestamp at which the token will expire - */ - declare expiresAt: Date - - constructor( - /** - * Reference to the user id for whom the token - * is generated - */ - public userId: string | number, - - /** - * Series is a random number stored inside the database as it is - */ - public series: string, - - /** - * Value is a random number only available at the time of issuing - * the token. Afterwards, the value is undefined. - */ - public value: string | undefined, - - /** - * Hash reference to the token hash - */ - public hash: string - ) { - super(series, value, hash) - } - - /** - * Create remember me token instance for a user - */ - static create(userId: string | number, expiry: string | number, size?: number): RememberMeToken { - const { series, value, hash } = this.seed(size) - const token = new RememberMeToken(userId, series, value, hash) - token.setExpiry(expiry) - - return token - } -} diff --git a/src/guards/session/token_providers/database.ts b/src/guards/session/token_providers/database.ts new file mode 100644 index 0000000..2513cab --- /dev/null +++ b/src/guards/session/token_providers/database.ts @@ -0,0 +1,83 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { RememberMeToken } from '../remember_me_token.js' +import type { RememberMeProviderContract } from '../types.js' +import { DatabaseTokenProvider } from '../../../core/token_providers/database.js' + +/** + * Representation of token within the database table + */ +type DatabaseTokenRow = { + series: string + user_id: string | number + type: string + guard: string + token: string + created_at: Date + updated_at: Date + expires_at: Date +} + +/** + * Remember me token provider to persist tokens inside the database + * using db query builder. + */ +export class DatabaseRememberTokenProvider + extends DatabaseTokenProvider + implements RememberMeProviderContract +{ + /** + * Prepares a token from the database result + */ + protected prepareToken(dbRow: DatabaseTokenRow): RememberMeToken | null { + const token = RememberMeToken.createFromPersisted(dbRow.user_id, dbRow.guard, dbRow.series) + token.hash = dbRow.token + token.guard = dbRow.guard + token.createdAt = + typeof dbRow.created_at === 'number' ? new Date(dbRow.created_at) : dbRow.created_at + token.updatedAt = + typeof dbRow.updated_at === 'number' ? new Date(dbRow.updated_at) : dbRow.updated_at + token.expiresAt = + typeof dbRow.expires_at === 'number' ? new Date(dbRow.expires_at) : dbRow.expires_at + + /** + * Ensure the token fetched from db is of same type. Otherwise + * return null + */ + if (dbRow.type !== token.type) { + return null + } + + /** + * Ensure the token is not expired + */ + if (token.expiresAt < new Date()) { + return null + } + + return token + } + + /** + * Converts the remember me token into a database row + */ + protected parseToken(token: RememberMeToken): DatabaseTokenRow { + return { + series: token.series, + user_id: token.userId, + type: token.type, + token: token.hash, + guard: token.guard, + created_at: token.createdAt, + updated_at: token.updatedAt, + expires_at: token.expiresAt, + } + } +} diff --git a/src/guards/session/token_providers/main.ts b/src/guards/session/token_providers/main.ts deleted file mode 100644 index 25154b2..0000000 --- a/src/guards/session/token_providers/main.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { RememberMeToken } from '../token.js' -import type { RememberMeProviderContract } from '../types.js' -import { DatabaseTokenProvider } from '../../../core/token_providers/database.js' - -/** - * Remember me token provider to persist tokens inside the database - * using db query builder. - */ -export class DatabaseRememberTokenProvider - extends DatabaseTokenProvider - implements RememberMeProviderContract -{ - /** - * Prepares a token from the database result - */ - protected prepareToken(dbRow: { - series: string - user_id: string | number - type: string - token: string - created_at: Date - updated_at: Date - expires_at: Date | null - }): RememberMeToken { - const token = new RememberMeToken(dbRow.user_id, dbRow.series, undefined, dbRow.token) - if (dbRow.expires_at) { - token.expiresAt = dbRow.expires_at - } - token.createdAt = dbRow.created_at - token.updatedAt = dbRow.updated_at - - return token - } - - /** - * Converts the remember me token into a database row - */ - protected parseToken(token: RememberMeToken): { - series: string - user_id: string | number - type: string - token: string - created_at: Date - updated_at: Date - expires_at: Date | null - } { - return { - series: token.series, - user_id: token.userId, - type: token.type, - token: token.hash, - created_at: token.createdAt, - updated_at: token.updatedAt, - expires_at: token.expiresAt, - } - } -} diff --git a/src/guards/session/types.ts b/src/guards/session/types.ts index 2bddd62..367cd62 100644 --- a/src/guards/session/types.ts +++ b/src/guards/session/types.ts @@ -10,7 +10,7 @@ import type { Exception } from '@poppinss/utils' import type { HttpContext } from '@adonisjs/core/http' -import type { RememberMeToken } from './token.js' +import type { RememberMeToken } from './remember_me_token.js' import type { UserProviderContract, TokenProviderContract, diff --git a/tests/guards/session/attempt.spec.ts b/tests/guards/session/attempt.spec.ts index a19b19d..8516b90 100644 --- a/tests/guards/session/attempt.spec.ts +++ b/tests/guards/session/attempt.spec.ts @@ -12,7 +12,7 @@ import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' @@ -26,7 +26,8 @@ test.group('Session guard | attempt', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [credentialsVerified] = await Promise.all([ @@ -39,8 +40,13 @@ test.group('Session guard | attempt', () => { assert.strictEqual(credentialsVerified?.user, sessionGuard.user) assert.equal(credentialsVerified?.uid, sessionGuard.user!.email) assert.equal(sessionGuard.user!.id, user.id) - // since the attempt method will fetch from db + + /** + * since the attempt method will fetch user from db, the local + * and refetched instances will be different + */ assert.notStrictEqual(sessionGuard.user, user) + assert.isFalse(sessionGuard.isLoggedOut) assert.isFalse(sessionGuard.isAuthenticated) assert.isFalse(sessionGuard.authenticationAttempted) @@ -56,7 +62,7 @@ test.group('Session guard | attempt', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginFailed, attemptResult] = await Promise.allSettled([ @@ -79,7 +85,7 @@ test.group('Session guard | attempt', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginFailed, attemptResult] = await Promise.allSettled([ diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts index 04bdfcf..bd1c102 100644 --- a/tests/guards/session/authenticate.spec.ts +++ b/tests/guards/session/authenticate.spec.ts @@ -11,10 +11,10 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { RememberMeToken } from '../../../src/guards/session/token.js' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' -import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/main.js' +import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' +import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' import { pEvent, timeTravel, @@ -25,7 +25,7 @@ import { createEmitter, } from '../../helpers.js' -test.group('Session guard | authenticate', () => { +test.group('Session guard | authenticate | session id', () => { test('authenticate existing session for auth', async ({ assert, expectTypeOf }) => { const db = await createDatabase() await createTables(db) @@ -33,7 +33,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authSucceeded] = await Promise.all([ @@ -63,7 +63,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authFailed, authenticateCall] = await Promise.allSettled([ @@ -86,8 +86,9 @@ test.group('Session guard | authenticate', () => { await createTables(db) const ctx = new HttpContextFactory().create() + const emitter = createEmitter() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await user.delete() @@ -97,8 +98,12 @@ test.group('Session guard | authenticate', () => { await sessionGuard.authenticate() }) }).throws('Invalid or expired authentication session') +}) - test('login user via remember me token when session does not have user', async ({ assert }) => { +test.group('Session guard | authenticate | remember me token', () => { + test('create session when authentication is sucessful via remember me tokens', async ({ + assert, + }) => { const db = await createDatabase() await createTables(db) @@ -107,18 +112,18 @@ test.group('Session guard | authenticate', () => { const user = await FactoryUser.createWithDefaults() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) const sessionMiddleware = await new SessionMiddlewareFactory().create() + const sessionGuard = new SessionGuardFactory() - .create(ctx) + .create(ctx, emitter) .withRememberMeTokens(tokensProvider) - .setEmitter(emitter) - const token = RememberMeToken.create(user.id, '1 year') + const token = RememberMeToken.create(user.id, '1 year', 'web') await tokensProvider.createToken(token) ctx.request.request.headers.cookie = defineCookies([ { key: 'remember_web', - value: token.value!, + value: token.value!.release(), type: 'encrypted', }, ]) @@ -148,20 +153,23 @@ test.group('Session guard | authenticate', () => { assert.equal(freshToken!.hash, token.hash) const parsedCookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) - assert.equal(parsedCookies.remember_web.value, token.value) + assert.equal(parsedCookies.remember_web.value, token.value!.release()) }) - test('refresh remember me token when using it after 1 min of last update', async ({ assert }) => { + test('recycle remember me token when using it after 1 min of last update', async ({ assert }) => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(user.id, '1 year') + const token = RememberMeToken.create(user.id, '1 year', 'web') await tokensProvider.createToken(token) /** @@ -172,7 +180,7 @@ test.group('Session guard | authenticate', () => { ctx.request.request.headers.cookie = defineCookies([ { key: 'remember_web', - value: token.value!, + value: token.value!.release(), type: 'encrypted', }, ]) @@ -188,11 +196,14 @@ test.group('Session guard | authenticate', () => { assert.isTrue(sessionGuard.viaRemember) assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) + const decodedToken = RememberMeToken.decode(cookies.remember_web.value)! + /** * Since the token was generated within 1 minute of using * it. We do not refresh it inside the db */ - const freshToken = await tokensProvider.getTokenBySeries(token.series) + const freshToken = await tokensProvider.getTokenBySeries(decodedToken.series) assert.notEqual(freshToken!.hash, token.hash) assert.equal(freshToken!.series, token.series) @@ -204,9 +215,12 @@ test.group('Session guard | authenticate', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() ctx.request.request.headers.cookie = defineCookies([ @@ -226,13 +240,16 @@ test.group('Session guard | authenticate', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(user.id, '1 minute') + const token = RememberMeToken.create(user.id, '1 minute', 'web') await tokensProvider.createToken(token) /** @@ -243,7 +260,7 @@ test.group('Session guard | authenticate', () => { ctx.request.request.headers.cookie = defineCookies([ { key: 'remember_web', - value: token.value!, + value: token.value!.release(), type: 'encrypted', }, ]) @@ -257,18 +274,21 @@ test.group('Session guard | authenticate', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(user.id, '1 year') + const token = RememberMeToken.create(user.id, '1 year', 'web') ctx.request.request.headers.cookie = defineCookies([ { key: 'remember_web', - value: token.value!, + value: token.value!.release(), type: 'encrypted', }, ]) @@ -278,17 +298,20 @@ test.group('Session guard | authenticate', () => { }) }).throws('Invalid or expired authentication session') - test('throw error when user for remember me token has been deleted', async () => { + test('throw error when user has been deleted', async () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(user.id, '1 year') + const token = RememberMeToken.create(user.id, '1 year', 'web') await tokensProvider.createToken(token) await user.delete() @@ -296,7 +319,42 @@ test.group('Session guard | authenticate', () => { ctx.request.request.headers.cookie = defineCookies([ { key: 'remember_web', - value: token.value!, + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, async () => { + await sessionGuard.authenticate() + }) + }).throws('Invalid or expired authentication session') + + test('throw error when remember me token type does not match', async () => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const token = RememberMeToken.create(user.id, '1 year', 'web') + await tokensProvider.createToken(token) + + /** + * A matching token generated for different purpose should + * fail. + */ + await db.from('remember_me_tokens').where('series', token.series).update({ type: 'foo_token' }) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), type: 'encrypted', }, ]) @@ -310,9 +368,10 @@ test.group('Session guard | authenticate', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -323,26 +382,56 @@ test.group('Session guard | authenticate', () => { assert.equal(authUser.id, user.id) }) }) +}) - test('silently authenticate using the check method', async ({ assert }) => { +test.group('Session guard | check', () => { + test('return logged-in user when check method called', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const [authFailed, authenticateCall] = await Promise.allSettled([ + pEvent(emitter, 'session_auth:authentication_failed'), + sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + return sessionGuard.check() + }), + ]) + + assert.equal(authFailed.status, 'fulfilled') + assert.equal(authenticateCall.status, 'fulfilled') + if (authenticateCall.status === 'fulfilled') { + assert.isTrue(authenticateCall.value) + } + }) + + test('do not throw error when auth.check is used with non-logged in user', async ({ assert }) => { const db = await createDatabase() await createTables(db) const emitter = createEmitter() const ctx = new HttpContextFactory().create() await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authFailed, authenticateCall] = await Promise.allSettled([ pEvent(emitter, 'session_auth:authentication_failed'), sessionMiddleware.handle(ctx, async () => { - await sessionGuard.check() + return sessionGuard.check() }), ]) assert.equal(authFailed.status, 'fulfilled') assert.equal(authenticateCall.status, 'fulfilled') + if (authenticateCall.status === 'fulfilled') { + assert.isFalse(authenticateCall.value) + } }) test('throw error when calling authenticate after check', async ({ assert }) => { @@ -352,7 +441,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [authFailed, authenticateCall] = await Promise.allSettled([ @@ -370,7 +459,9 @@ test.group('Session guard | authenticate', () => { 'Invalid or expired authentication session' ) }) +}) +test.group('Session guard | authenticateAsClient', () => { test('get authentication session via authenticateAsClient', async ({ assert }) => { const db = await createDatabase() await createTables(db) @@ -378,7 +469,7 @@ test.group('Session guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) assert.deepEqual(await sessionGuard.authenticateAsClient(user), { session: { diff --git a/tests/guards/session/define_config.spec.ts b/tests/guards/session/define_config.spec.ts index 22dcc65..91ea688 100644 --- a/tests/guards/session/define_config.spec.ts +++ b/tests/guards/session/define_config.spec.ts @@ -14,9 +14,9 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { HashManagerFactory } from '@adonisjs/core/factories/hash' import { providers } from '../../../index.js' -import { FactoryUser } from '../../../factories/main.js' import { createDatabase, createEmitter } from '../../helpers.js' import { SessionGuard } from '../../../src/guards/session/guard.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { LucidUserProvider } from '../../../src/auth/user_providers/main.js' import { sessionGuard, tokensProvider } from '../../../src/guards/session/define_config.js' diff --git a/tests/guards/session/get_user.spec.ts b/tests/guards/session/get_user.spec.ts index aced9a4..892b04d 100644 --- a/tests/guards/session/get_user.spec.ts +++ b/tests/guards/session/get_user.spec.ts @@ -11,8 +11,8 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { createTables, createDatabase } from '../../helpers.js' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { createTables, createDatabase, createEmitter } from '../../helpers.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' test.group('Session guard | getUser', () => { @@ -20,9 +20,10 @@ test.group('Session guard | getUser', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -38,8 +39,9 @@ test.group('Session guard | getUser', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await assert.rejects(async () => { diff --git a/tests/guards/session/login.spec.ts b/tests/guards/session/login.spec.ts index 8cd87a7..c9d7f66 100644 --- a/tests/guards/session/login.spec.ts +++ b/tests/guards/session/login.spec.ts @@ -11,10 +11,10 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { RememberMeToken } from '../../../src/guards/session/token.js' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' -import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/main.js' +import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' import { createDatabase, createEmitter, createTables, pEvent, parseCookies } from '../../helpers.js' test.group('Session guard | login', () => { @@ -22,9 +22,10 @@ test.group('Session guard | login', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -45,7 +46,7 @@ test.group('Session guard | login', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginAttempted, loginSucceeded] = await Promise.all([ @@ -66,10 +67,13 @@ test.group('Session guard | login', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -109,9 +113,10 @@ test.group('Session guard | login', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -125,9 +130,10 @@ test.group('Session guard | login', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) await sessionGuard.login(user) }).throws( diff --git a/tests/guards/session/login_via_id.spec.ts b/tests/guards/session/login_via_id.spec.ts index 4c0939a..d8d4504 100644 --- a/tests/guards/session/login_via_id.spec.ts +++ b/tests/guards/session/login_via_id.spec.ts @@ -12,7 +12,7 @@ import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' @@ -26,7 +26,7 @@ test.group('Session guard | loginViaId', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await Promise.all([ @@ -50,7 +50,7 @@ test.group('Session guard | loginViaId', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const [loginFailed, attemptResult] = await Promise.allSettled([ diff --git a/tests/guards/session/logout.spec.ts b/tests/guards/session/logout.spec.ts index 177946a..ad82324 100644 --- a/tests/guards/session/logout.spec.ts +++ b/tests/guards/session/logout.spec.ts @@ -7,34 +7,32 @@ * file that was distributed with this source code. */ -import { Socket } from 'node:net' import { test } from '@japa/runner' -import { IncomingMessage } from 'node:http' -import { CookieClient } from '@adonisjs/core/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { HttpContextFactory, RequestFactory } from '@adonisjs/core/factories/http' +import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { RememberMeToken } from '../../../src/guards/session/token.js' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' +import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' import { pEvent, - encryption, createTables, parseCookies, createEmitter, createDatabase, + defineCookies, } from '../../helpers.js' -import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/main.js' +import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' test.group('Session guard | logout', () => { test('logout user by deleting auth data from session store', async ({ assert }) => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -58,28 +56,28 @@ test.group('Session guard | logout', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) const user = await FactoryUser.createWithDefaults() const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(user.id, '1 year') + const token = RememberMeToken.create(user.id, '1 year', 'web') await tokensProvider.createToken(token) - const client = new CookieClient(encryption) - const req = new IncomingMessage(new Socket()) - req.headers['cookie'] = `remember_web=${client.encrypt('remember_web', token.value)};` - - const ctx = new HttpContextFactory() - .merge({ - request: new RequestFactory() - .merge({ - req, - }) - .create(), - }) - .create() - - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const ctx = new HttpContextFactory().create() + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) + await sessionMiddleware.handle(ctx, async () => { await sessionGuard.logout() }) @@ -99,10 +97,10 @@ test.group('Session guard | logout', () => { const db = await createDatabase() await createTables(db) - const ctx = new HttpContextFactory().create() const emitter = createEmitter() + const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -133,24 +131,22 @@ test.group('Session guard | logout', () => { const db = await createDatabase() await createTables(db) + const emitter = createEmitter() const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const client = new CookieClient(encryption) - const req = new IncomingMessage(new Socket()) - req.headers['cookie'] = `remember_web=${client.encrypt('remember_web', 'foo')};` - - const ctx = new HttpContextFactory() - .merge({ - request: new RequestFactory() - .merge({ - req, - }) - .create(), - }) - .create() - - const sessionGuard = new SessionGuardFactory().create(ctx).withRememberMeTokens(tokensProvider) + const ctx = new HttpContextFactory().create() + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: 'foo', + type: 'encrypted', + }, + ]) + + const sessionGuard = new SessionGuardFactory() + .create(ctx, emitter) + .withRememberMeTokens(tokensProvider) await sessionMiddleware.handle(ctx, async () => { await sessionGuard.logout() }) diff --git a/tests/guards/session/remember_me_db_provider.spec.ts b/tests/guards/session/remember_me_db_provider.spec.ts new file mode 100644 index 0000000..e65ea94 --- /dev/null +++ b/tests/guards/session/remember_me_db_provider.spec.ts @@ -0,0 +1,86 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { createDatabase, createTables, timeTravel } from '../../helpers.js' +import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' +import { DatabaseRememberTokenFactory } from '../../../factories/guards/session/database_remember_token_factory.js' + +test.group('Remember me token provider', () => { + test('persist remember me token to the database', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = RememberMeToken.create(1, '20mins', 'web') + const provider = new DatabaseRememberTokenFactory().create(db) + + await provider.createToken(token) + const tokens = await db.from('remember_me_tokens') + + assert.lengthOf(tokens, 1) + assert.equal(tokens[0].user_id, 1) + assert.equal(tokens[0].series, token.series) + assert.equal(tokens[0].token, token.hash) + assert.equal(tokens[0].guard, 'web') + assert.equal(tokens[0].type, 'remember_me_token') + assert.isDefined(tokens[0].created_at) + assert.isDefined(tokens[0].updated_at) + assert.equal(new Date(tokens[0].expires_at).getTime(), token.expiresAt.getTime()) + }) + + test('get token by series', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = RememberMeToken.create(1, '20mins', 'web') + const provider = new DatabaseRememberTokenFactory().create(db) + + await provider.createToken(token) + const freshToken = (await provider.getTokenBySeries(token.series))! + + assert.instanceOf(freshToken, RememberMeToken) + assert.equal(freshToken.series, token.series) + assert.isUndefined(freshToken.value) + assert.equal(freshToken.hash, token.hash) + assert.equal(freshToken.guard, 'web') + assert.equal(freshToken.type, 'remember_me_token') + assert.equal(freshToken.createdAt.getTime(), token.createdAt.getTime()) + assert.equal(freshToken.updatedAt.getTime(), token.updatedAt.getTime()) + assert.equal(freshToken.expiresAt.getTime(), token.expiresAt.getTime()) + }) + + test('return null when token has been expired', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = RememberMeToken.create(1, '20mins', 'web') + const provider = new DatabaseRememberTokenFactory().create(db) + + await provider.createToken(token) + timeTravel(21 * 60) // travel by 21 mins + + const freshToken = await provider.getTokenBySeries(token.series) + assert.isNull(freshToken) + }) + + test('return null when token type mismatches', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const token = RememberMeToken.create(1, '20mins', 'web') + const provider = new DatabaseRememberTokenFactory().create(db) + + await provider.createToken(token) + + await db.from('remember_me_tokens').where('series', token.series).update({ type: 'foo' }) + const freshToken = await provider.getTokenBySeries(token.series) + assert.isNull(freshToken) + }) +}) diff --git a/tests/guards/session/remember_me_token.spec.ts b/tests/guards/session/remember_me_token.spec.ts new file mode 100644 index 0000000..a5a7fef --- /dev/null +++ b/tests/guards/session/remember_me_token.spec.ts @@ -0,0 +1,111 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createHash } from 'node:crypto' +import { Secret, base64 } from '@adonisjs/core/helpers' + +import { freezeTime } from '../../helpers.js' +import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' + +test.group('Remember me token', () => { + test('create a remember me token', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = RememberMeToken.create(1, '20mins', 'web') + assert.equal(token.userId, 1) + assert.equal(token.createdAt.getTime(), date.getTime()) + assert.equal(token.updatedAt.getTime(), date.getTime()) + assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) + assert.lengthOf(token.series, 15) + assert.instanceOf(token.value, Secret) + assert.equal(token.guard, 'web') + assert.equal(token.type, 'remember_me_token') + assert.equal( + token.hash, + createHash('sha256') + .update(base64.urlDecode(token.value!.release().split('.')[1])!) + .digest('hex') + ) + }) + + test('create a remember me token from persisted data', ({ assert }) => { + const token = RememberMeToken.createFromPersisted(1, 'web', '1234') + assert.equal(token.series, '1234') + assert.equal(token.userId, 1) + assert.equal(token.guard, 'web') + assert.equal(token.type, 'remember_me_token') + assert.isUndefined(token.createdAt) + assert.isUndefined(token.updatedAt) + assert.isUndefined(token.expiresAt) + assert.isUndefined(token.value) + assert.isUndefined(token.hash) + }) + + test('refresh remember me token', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = RememberMeToken.createFromPersisted(1, 'web', '1234') + token.refresh('20mins') + + /** + * Still undefined because refresh method does not update + * createdAt timestamp. The token providers should do + * that + */ + assert.isUndefined(token.createdAt) + + assert.equal(token.userId, 1) + assert.equal(token.updatedAt.getTime(), date.getTime()) + assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) + assert.equal(token.series, '1234') + assert.instanceOf(token.value, Secret) + assert.equal(token.guard, 'web') + assert.equal(token.type, 'remember_me_token') + assert.equal( + token.hash, + createHash('sha256') + .update(base64.urlDecode(token.value!.release().split('.')[1])!) + .digest('hex') + ) + }) + + test('verify token hash', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = RememberMeToken.createFromPersisted(1, 'web', '1234') + token.refresh('20mins') + assert.isTrue(token.verify(base64.urlDecode(token.value!.release().split('.')[1])!)) + }) + + test('decode remember me token', ({ assert }) => { + const token = RememberMeToken.create(1, '20mins', 'web') + const { series, value } = RememberMeToken.decode(token.value!.release())! + + assert.equal(series, token.series) + assert.isTrue(token.verify(value)) + }) + + test('fail to decode invalid values', ({ assert }) => { + assert.isNull(RememberMeToken.decode(null as any)) + assert.isNull(RememberMeToken.decode('')) + assert.isNull(RememberMeToken.decode('...')) + assert.isNull(RememberMeToken.decode('foobar')) + assert.isNull(RememberMeToken.decode('foo.bar')) + }) +}) From 5ceac4fd313f59e4d2c9bb4002862482fc6906f8 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 15:22:25 +0530 Subject: [PATCH 55/96] refactor: update debug calls --- src/core/user_providers/lucid.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts index 53068a6..5130324 100644 --- a/src/core/user_providers/lucid.ts +++ b/src/core/user_providers/lucid.ts @@ -85,7 +85,7 @@ export abstract class BaseLucidUserProvider Date: Thu, 11 Jan 2024 15:36:50 +0530 Subject: [PATCH 56/96] feat: clean and initiate opaque tokens implementation --- factories/basic_auth_guard_factory.ts | 33 ------- factories/database_token_factory.ts | 79 ----------------- factories/database_user_provider.ts | 32 ------- factories/guards/basic_auth/guard_factory.ts | 45 ++++++++++ factories/guards/basic_auth/main.ts | 10 +++ .../session/guard_factory.ts} | 16 +++- factories/guards/session/main.ts | 11 +++ factories/lucid_user_provider.ts | 75 ---------------- factories/main.ts | 21 ----- package.json | 2 +- src/guards/basic_auth/define_config.ts | 3 +- src/guards/basic_auth/guard.ts | 56 +++++------- src/guards/opaque_tokens/token.ts | 75 ++++++++++++++++ .../opaque_tokens/token_providers/redis.ts | 52 +++++++++++ tests/auth/auth_manager.spec.ts | 4 +- tests/auth/authenticator.spec.ts | 12 +-- tests/auth/authenticator_client.spec.ts | 10 +-- tests/auth/define_config.spec.ts | 2 +- tests/guards/basic_auth/authenticate.spec.ts | 88 ++++++++++--------- tests/guards/session/attempt.spec.ts | 2 +- tests/guards/session/authenticate.spec.ts | 2 +- tests/guards/session/get_user.spec.ts | 2 +- tests/guards/session/login.spec.ts | 2 +- tests/guards/session/login_via_id.spec.ts | 2 +- tests/guards/session/logout.spec.ts | 2 +- tests/helpers.ts | 32 ++++++- 26 files changed, 328 insertions(+), 342 deletions(-) delete mode 100644 factories/basic_auth_guard_factory.ts delete mode 100644 factories/database_token_factory.ts delete mode 100644 factories/database_user_provider.ts create mode 100644 factories/guards/basic_auth/guard_factory.ts create mode 100644 factories/guards/basic_auth/main.ts rename factories/{session_guard_factory.ts => guards/session/guard_factory.ts} (67%) create mode 100644 factories/guards/session/main.ts delete mode 100644 factories/lucid_user_provider.ts delete mode 100644 factories/main.ts create mode 100644 src/guards/opaque_tokens/token.ts create mode 100644 src/guards/opaque_tokens/token_providers/redis.ts diff --git a/factories/basic_auth_guard_factory.ts b/factories/basic_auth_guard_factory.ts deleted file mode 100644 index a6fffe0..0000000 --- a/factories/basic_auth_guard_factory.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { HttpContext } from '@adonisjs/core/http' -import { - FactoryUser, - TestLucidUserProvider, - LucidUserProviderFactory, -} from './lucid_user_provider.js' -import { BasicAuthGuard } from '../src/guards/basic_auth/guard.js' -import type { UserProviderContract } from '../src/core/types.js' - -/** - * Exposes the API to create a basic auth guard for testing. Under - * the hood configures Lucid models for looking up users - */ -export class BasicAuthGuardFactory { - merge() { - return this - } - - create< - UserProvider extends UserProviderContract = TestLucidUserProvider, - >(ctx: HttpContext, provider?: UserProvider) { - return new BasicAuthGuard('basic', ctx, provider || new LucidUserProviderFactory().create()) - } -} diff --git a/factories/database_token_factory.ts b/factories/database_token_factory.ts deleted file mode 100644 index 33962ca..0000000 --- a/factories/database_token_factory.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Database } from '@adonisjs/lucid/database' -import { Token } from '../src/core/token.js' -import { DatabaseTokenProvider } from '../src/core/token_providers/database.js' - -/** - * Representation of token used for testing - */ -export class TestToken extends Token { - type = 'test_token' - - declare userId: string | number - - static create(userId: number | string, expiry: string | number, size?: number): TestToken { - const { series, value, hash } = this.seed(size) - const token = new TestToken(series, value, hash) - token.setExpiry(expiry) - token.userId = userId - - return token - } -} - -/** - * Test implementation of the database token provider - */ -export class TestDatabaseTokenProvider extends DatabaseTokenProvider { - protected prepareToken(dbRow: { - series: string - user_id: string | number - type: string - token: string - created_at: Date - expires_at: Date | null - }): TestToken { - const token = new TestToken(dbRow.series, undefined, dbRow.token) - token.createdAt = dbRow.created_at - if (dbRow.expires_at) { - token.expiresAt = dbRow.expires_at - } - return token - } - - protected parseToken(token: TestToken): { - series: string - user_id: string | number - type: string - token: string - created_at: Date - updated_at: Date - expires_at: Date | null - } { - return { - series: token.series, - user_id: token.userId, - type: token.type, - token: token.hash, - created_at: token.createdAt, - updated_at: token.createdAt, - expires_at: token.expiresAt || null, - } - } -} - -export class DatabaseTokenProviderFactory { - create(db: Database) { - return new TestDatabaseTokenProvider(db, { - table: 'remember_me_tokens', - }) - } -} diff --git a/factories/database_user_provider.ts b/factories/database_user_provider.ts deleted file mode 100644 index 3b8c96d..0000000 --- a/factories/database_user_provider.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Hash } from '@adonisjs/core/hash' -import type { Database } from '@adonisjs/lucid/database' -import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' -import { BaseDatabaseUserProvider } from '../src/core/user_providers/database.js' - -export class TestDatabaseUserProvider< - RealUser extends Record, -> extends BaseDatabaseUserProvider {} - -/** - * Creates an instance of the DatabaseUserProvider with sane - * defaults for testing - */ -export class DatabaseUserProviderFactory { - create(db: Database) { - return new TestDatabaseUserProvider(db, new Hash(new Scrypt({})), { - id: 'id', - table: 'users', - passwordColumnName: 'password', - uids: ['email', 'username'], - }) - } -} diff --git a/factories/guards/basic_auth/guard_factory.ts b/factories/guards/basic_auth/guard_factory.ts new file mode 100644 index 0000000..6362fa8 --- /dev/null +++ b/factories/guards/basic_auth/guard_factory.ts @@ -0,0 +1,45 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import { EmitterLike } from '@adonisjs/core/types/events' +import { + FactoryUser, + TestLucidUserProvider, + LucidUserProviderFactory, +} from '../../core/lucid_user_provider.js' +import { PROVIDER_REAL_USER } from '../../../src/auth/symbols.js' +import type { UserProviderContract } from '../../../src/core/types.js' +import { BasicAuthGuard } from '../../../src/guards/basic_auth/guard.js' +import { BasicAuthGuardEvents } from '../../../src/guards/basic_auth/types.js' + +/** + * Exposes the API to create a basic auth guard for testing. Under + * the hood configures Lucid models for looking up users + */ +export class BasicAuthGuardFactory { + merge() { + return this + } + + create< + UserProvider extends UserProviderContract = TestLucidUserProvider, + >( + ctx: HttpContext, + emitter: EmitterLike>, + provider?: UserProvider + ) { + return new BasicAuthGuard( + 'basic', + ctx, + emitter, + provider || new LucidUserProviderFactory().create() + ) + } +} diff --git a/factories/guards/basic_auth/main.ts b/factories/guards/basic_auth/main.ts new file mode 100644 index 0000000..cf06f27 --- /dev/null +++ b/factories/guards/basic_auth/main.ts @@ -0,0 +1,10 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export { BasicAuthGuardFactory } from './guard_factory.js' diff --git a/factories/session_guard_factory.ts b/factories/guards/session/guard_factory.ts similarity index 67% rename from factories/session_guard_factory.ts rename to factories/guards/session/guard_factory.ts index c445017..cdca68a 100644 --- a/factories/session_guard_factory.ts +++ b/factories/guards/session/guard_factory.ts @@ -9,16 +9,19 @@ import type { HttpContext } from '@adonisjs/core/http' -import { SessionGuard } from '../src/guards/session/guard.js' +import { SessionGuard } from '../../../src/guards/session/guard.js' import type { SessionGuardConfig, + SessionGuardEvents, SessionUserProviderContract, -} from '../src/guards/session/types.js' +} from '../../../src/guards/session/types.js' import { FactoryUser, TestLucidUserProvider, LucidUserProviderFactory, -} from './lucid_user_provider.js' +} from '../../core/lucid_user_provider.js' +import { EmitterLike } from '@adonisjs/core/types/events' +import { PROVIDER_REAL_USER } from '../../../src/auth/symbols.js' /** * Exposes the API to create a session guard for testing. Under @@ -36,11 +39,16 @@ export class SessionGuardFactory { UserProvider extends SessionUserProviderContract = TestLucidUserProvider< typeof FactoryUser >, - >(ctx: HttpContext, provider?: UserProvider) { + >( + ctx: HttpContext, + emitter: EmitterLike>, + provider?: UserProvider + ) { return new SessionGuard( 'web', this.#config, ctx, + emitter, provider || new LucidUserProviderFactory().create() ) } diff --git a/factories/guards/session/main.ts b/factories/guards/session/main.ts new file mode 100644 index 0000000..86a4e16 --- /dev/null +++ b/factories/guards/session/main.ts @@ -0,0 +1,11 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export { SessionGuardFactory } from './guard_factory.js' +export { DatabaseRememberTokenFactory } from './database_remember_token_factory.js' diff --git a/factories/lucid_user_provider.ts b/factories/lucid_user_provider.ts deleted file mode 100644 index 5bba2b4..0000000 --- a/factories/lucid_user_provider.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Hash } from '@adonisjs/core/hash' -import { BaseModel, column } from '@adonisjs/lucid/orm' -import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' - -import { PROVIDER_REAL_USER } from '../src/auth/symbols.js' -import { BaseLucidUserProvider } from '../src/core/user_providers/lucid.js' -import type { LucidAuthenticatable, LucidUserProviderOptions } from '../src/core/types.js' - -export class FactoryUser extends BaseModel { - static table = 'users' - - static createWithDefaults(attributes?: { - email?: string - password?: string | null - username?: string - }) { - return this.create({ - email: 'foo@bar.com', - username: 'foo', - password: 'secret', - ...attributes, - }) - } - - @column() - declare id: number - - @column() - declare username: string - - @column() - declare email: string - - @column() - declare password: string | null -} - -export class TestLucidUserProvider< - UserModel extends LucidAuthenticatable, -> extends BaseLucidUserProvider { - declare [PROVIDER_REAL_USER]: InstanceType -} - -/** - * Creates an instance of the LucidUserProvider with sane - * defaults for testing - */ -export class LucidUserProviderFactory { - createForModel(options: LucidUserProviderOptions) { - return new TestLucidUserProvider(new Hash(new Scrypt({})), { - ...options, - }) - } - - create() { - return this.createForModel({ - model: async () => { - return { - default: FactoryUser, - } - }, - passwordColumnName: 'password', - uids: ['email', 'username'], - }) - } -} diff --git a/factories/main.ts b/factories/main.ts deleted file mode 100644 index d7fcf68..0000000 --- a/factories/main.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export { SessionGuardFactory } from './session_guard_factory.js' -export { DatabaseUserProviderFactory, TestDatabaseUserProvider } from './database_user_provider.js' -export { - FactoryUser, - LucidUserProviderFactory, - TestLucidUserProvider, -} from './lucid_user_provider.js' -export { - TestToken, - TestDatabaseTokenProvider, - DatabaseTokenProviderFactory, -} from './database_token_factory.js' diff --git a/package.json b/package.json index effdb17..723a62f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "./plugins/api_client": "./build/src/auth/plugins/japa/api_client.js", "./plugins/browser_client": "./build/src/auth/plugins/japa/browser_client.js", "./services/main": "./build/services/auth.js", - "./core/token": "./build/src/core/token.js", "./core/guard_user": "./build/src/core/guard_user.js", "./core/user_providers/*": "./build/src/core/user_providers/*.js", "./core/token_providers/*": "./build/src/core/token_providers/*.js", @@ -73,6 +72,7 @@ "@adonisjs/i18n": "^2.0.0", "@adonisjs/lucid": "^19.0.0", "@adonisjs/prettier-config": "^1.2.1", + "@adonisjs/redis": "^8.0.0", "@adonisjs/session": "^7.0.0", "@adonisjs/tsconfig": "^1.2.1", "@commitlint/cli": "^18.4.4", diff --git a/src/guards/basic_auth/define_config.ts b/src/guards/basic_auth/define_config.ts index cb233c5..397e762 100644 --- a/src/guards/basic_auth/define_config.ts +++ b/src/guards/basic_auth/define_config.ts @@ -42,8 +42,7 @@ export function basicAuthGuard { - const guard = new BasicAuthGuard(guardName, ctx, provider) - return guard.setEmitter(emitter) + return new BasicAuthGuard(guardName, ctx, emitter as any, provider) } }, } diff --git a/src/guards/basic_auth/guard.ts b/src/guards/basic_auth/guard.ts index a520929..d53572d 100644 --- a/src/guards/basic_auth/guard.ts +++ b/src/guards/basic_auth/guard.ts @@ -9,8 +9,8 @@ import auth from 'basic-auth' import type { HttpContext } from '@adonisjs/core/http' -import type { EmitterLike } from '@adonisjs/core/types/events' import { Exception, RuntimeException } from '@poppinss/utils' +import type { EmitterLike } from '@adonisjs/core/types/events' import debug from '../../auth/debug.js' import type { BasicAuthGuardEvents } from './types.js' @@ -46,7 +46,7 @@ export class BasicAuthGuard> /** * Emitter to emit events */ - #emitter?: EmitterLike> + #emitter: EmitterLike> /** * Driver name of the guard @@ -77,9 +77,15 @@ export class BasicAuthGuard> */ user?: UserProvider[typeof PROVIDER_REAL_USER] - constructor(name: string, ctx: HttpContext, userProvider: UserProvider) { + constructor( + name: string, + ctx: HttpContext, + emitter: EmitterLike>, + userProvider: UserProvider + ) { this.#ctx = ctx this.#name = name + this.#emitter = emitter this.#userProvider = userProvider } @@ -87,26 +93,14 @@ export class BasicAuthGuard> * Notifies about authentication failure and throws the exception */ #authenticationFailed(error: Exception): never { - if (this.#emitter) { - this.#emitter.emit('basic_auth:authentication_failed', { - ctx: this.#ctx, - guardName: this.#name, - error, - }) - } - + this.#emitter.emit('basic_auth:authentication_failed', { + ctx: this.#ctx, + guardName: this.#name, + error, + }) throw error } - /** - * Register an event emitter to listen for global events for - * authentication lifecycle. - */ - setEmitter(emitter: EmitterLike): this { - this.#emitter = emitter - return this - } - /** * Returns an instance of the authenticated user. Or throws * an exception if the request is not authenticated. @@ -155,12 +149,10 @@ export class BasicAuthGuard> * Beginning authentication attempt */ this.authenticationAttempted = true - if (this.#emitter) { - this.#emitter.emit('basic_auth:authentication_attempted', { - ctx: this.#ctx, - guardName: this.#name, - }) - } + this.#emitter.emit('basic_auth:authentication_attempted', { + ctx: this.#ctx, + guardName: this.#name, + }) /** * Fetch credentials from the header @@ -180,13 +172,11 @@ export class BasicAuthGuard> debug('basic_auth_guard: marking user as authenticated') - if (this.#emitter) { - this.#emitter.emit('basic_auth:authentication_succeeded', { - ctx: this.#ctx, - guardName: this.#name, - user: this.user, - }) - } + this.#emitter.emit('basic_auth:authentication_succeeded', { + ctx: this.#ctx, + guardName: this.#name, + user: this.user, + }) /** * Return user diff --git a/src/guards/opaque_tokens/token.ts b/src/guards/opaque_tokens/token.ts new file mode 100644 index 0000000..50a5069 --- /dev/null +++ b/src/guards/opaque_tokens/token.ts @@ -0,0 +1,75 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Token } from '../../core/token.js' + +/** + * Opaque tokens are generated to authenticate users via stateless tokens. + */ +export class OpaqueToken extends Token { + /** + * Static name for the token to uniquely identify a + * bucket of tokens + */ + readonly type: 'opaque_token' = 'opaque_token' + + /** + * Timestamp at which the token will expire + */ + declare expiresAt: Date + + /** + * The guard for which the opaque token was generated + */ + declare guard: string + + constructor( + /** + * Reference to the user id for whom the token + * is generated + */ + public userId: string | number, + + /** + * Series is a random value stored inside the database as it is. + * The series is generated via the seed method + */ + public series: string, + + /** + * Value is a random value only available at the time of issuing + * the token. Afterwards, the value is undefined. + */ + public value: string | undefined, + + /** + * Hash reference to the token hash + */ + public hash: string + ) { + super(series, value, hash) + } + + /** + * Create an opaque token for a user + */ + static create( + userId: string | number, + expiry: string | number, + guard: string, + size?: number + ): OpaqueToken { + const { series, value, hash } = this.seed(size) + const token = new OpaqueToken(userId, series, value, hash) + token.guard = guard + token.setExpiry(expiry) + + return token + } +} diff --git a/src/guards/opaque_tokens/token_providers/redis.ts b/src/guards/opaque_tokens/token_providers/redis.ts new file mode 100644 index 0000000..2669208 --- /dev/null +++ b/src/guards/opaque_tokens/token_providers/redis.ts @@ -0,0 +1,52 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Connection } from '@adonisjs/redis/types' + +import { OpaqueToken } from '../token.js' +import type { TokenProviderContract } from '../../../core/types.js' + +export class RedisOpaqueTokenProvider implements TokenProviderContract { + constructor(protected redisConnection: Connection) {} + + /** + * Persists the opaque token inside the redis database + */ + async createToken(token: OpaqueToken): Promise { + const key = token.series + const value = JSON.stringify({ + guard: token.guard, + user_id: token.userId, + hash: token.hash, + meta_data: token.metaData, + }) + + const ttl = Math.ceil(Math.abs(token.expiresAt.getTime() - new Date().getTime()) / 1000) + await this.redisConnection.setex(key, ttl, value) + } + + /** + * Finds a token by series inside the redis database and returns an + * instance of it. + * + * Returns null if the token is missing or expired + */ + async getTokenBySeries(series: string): Promise { + const value = await this.redisConnection.get(series) + if (!value) { + return null + } + + const token = JSON.parse(value) + const opaqueToken = new OpaqueToken(token.user_id, series, undefined, token.hash) + opaqueToken.metaData = token.meta_data + opaqueToken.guard = token.guard + return opaqueToken + } +} diff --git a/tests/auth/auth_manager.spec.ts b/tests/auth/auth_manager.spec.ts index 41da2c7..2a3011f 100644 --- a/tests/auth/auth_manager.spec.ts +++ b/tests/auth/auth_manager.spec.ts @@ -13,13 +13,13 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { createEmitter } from '../helpers.js' import { AuthManager } from '../../src/auth/auth_manager.js' import { Authenticator } from '../../src/auth/authenticator.js' -import { SessionGuardFactory } from '../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../factories/guards/session/guard_factory.js' test.group('Auth manager', () => { test('create authenticator from auth manager', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const authManager = new AuthManager({ default: 'web', diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index 7afc924..472678d 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -12,15 +12,15 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { Authenticator } from '../../src/auth/authenticator.js' -import { FactoryUser } from '../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../factories/core/lucid_user_provider.js' import { createDatabase, createEmitter, createTables } from '../helpers.js' -import { SessionGuardFactory } from '../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../factories/guards/session/guard_factory.js' test.group('Authenticator', () => { test('create authenticator with guards', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const authenticator = new Authenticator(ctx, { default: 'web', @@ -36,7 +36,7 @@ test.group('Authenticator', () => { test('access guard using its name', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const authenticator = new Authenticator(ctx, { default: 'web', @@ -60,7 +60,7 @@ test.group('Authenticator', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const authenticator = new Authenticator(ctx, { @@ -92,7 +92,7 @@ test.group('Authenticator', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const sessionMiddleware = await new SessionMiddlewareFactory().create() const authenticator = new Authenticator(ctx, { diff --git a/tests/auth/authenticator_client.spec.ts b/tests/auth/authenticator_client.spec.ts index 49170ad..d43fbe4 100644 --- a/tests/auth/authenticator_client.spec.ts +++ b/tests/auth/authenticator_client.spec.ts @@ -10,16 +10,16 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { FactoryUser } from '../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../factories/core/lucid_user_provider.js' import { createDatabase, createEmitter, createTables } from '../helpers.js' -import { SessionGuardFactory } from '../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../factories/guards/session/guard_factory.js' import { AuthenticatorClient } from '../../src/auth/authenticator_client.js' test.group('Authenticator client', () => { test('create authenticator client with guards', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const client = new AuthenticatorClient({ default: 'web', @@ -35,7 +35,7 @@ test.group('Authenticator client', () => { test('access guard using its name', async ({ assert, expectTypeOf }) => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const client = new AuthenticatorClient({ default: 'web', @@ -60,7 +60,7 @@ test.group('Authenticator client', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx).setEmitter(emitter) + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const client = new AuthenticatorClient({ default: 'web', diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts index be41ddd..67c4312 100644 --- a/tests/auth/define_config.spec.ts +++ b/tests/auth/define_config.spec.ts @@ -16,7 +16,7 @@ import { HashManagerFactory } from '@adonisjs/core/factories/hash' import { createDatabase, createEmitter } from '../helpers.js' import { AuthManager } from '../../src/auth/auth_manager.js' import { Authenticator } from '../../src/auth/authenticator.js' -import { FactoryUser } from '../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../factories/core/lucid_user_provider.js' import { sessionGuard } from '../../src/guards/session/define_config.js' import { defineConfig, providers } from '../../src/auth/define_config.js' import { DatabaseUserProvider, LucidUserProvider } from '../../src/auth/user_providers/main.js' diff --git a/tests/guards/basic_auth/authenticate.spec.ts b/tests/guards/basic_auth/authenticate.spec.ts index c0eda3c..cf525ee 100644 --- a/tests/guards/basic_auth/authenticate.spec.ts +++ b/tests/guards/basic_auth/authenticate.spec.ts @@ -10,9 +10,9 @@ import { test } from '@japa/runner' import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { FactoryUser } from '../../../factories/lucid_user_provider.js' +import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { pEvent, createTables, createDatabase, createEmitter } from '../../helpers.js' -import { BasicAuthGuardFactory } from '../../../factories/basic_auth_guard_factory.js' +import { BasicAuthGuardFactory } from '../../../factories/guards/basic_auth/main.js' test.group('BasicAuth guard | authenticate', () => { test('authenticate user using credentials', async ({ assert, expectTypeOf }) => { @@ -25,7 +25,7 @@ test.group('BasicAuth guard | authenticate', () => { password: await new Scrypt({}).make('secret'), }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from( `${user.email}:secret` @@ -44,43 +44,13 @@ test.group('BasicAuth guard | authenticate', () => { assert.isTrue(basicAuthGuard.authenticationAttempted) }) - test('check if user is logged in using check method', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) - - ctx.request.request.headers.authorization = `Basic ${Buffer.from( - `${user.email}:secret` - ).toString('base64')}` - - const [authSucceeded, state] = await Promise.all([ - pEvent(emitter, 'basic_auth:authentication_succeeded'), - basicAuthGuard.check(), - ]) - - assert.isTrue(state) - expectTypeOf(basicAuthGuard.authenticate).returns.toMatchTypeOf>() - assert.equal(authSucceeded!.user.id, user.id) - assert.equal(authSucceeded!.user.id, basicAuthGuard.getUserOrFail().id) - assert.equal(basicAuthGuard.getUserOrFail().id, user.id) - assert.isTrue(basicAuthGuard.isAuthenticated) - assert.isTrue(basicAuthGuard.authenticationAttempted) - }) - test('throw error when credentials are missing', async ({ assert }) => { const db = await createDatabase() await createTables(db) const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) const [authFailed, authentication] = await Promise.allSettled([ pEvent(emitter, 'basic_auth:authentication_failed'), @@ -108,7 +78,7 @@ test.group('BasicAuth guard | authenticate', () => { const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from(`foo:secret`).toString( 'base64' @@ -143,7 +113,7 @@ test.group('BasicAuth guard | authenticate', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from( `${user.email}:wrongpassword` @@ -168,14 +138,16 @@ test.group('BasicAuth guard | authenticate', () => { assert.isFalse(basicAuthGuard.isAuthenticated) assert.isUndefined(basicAuthGuard.user) }) +}) - test('throw error when called getUserOrFail', async ({ assert }) => { +test.group('BasicAuth guard | getUserOrFail', () => { + test('throw error when using getUserOrFail and user is authenticated', async ({ assert }) => { const db = await createDatabase() await createTables(db) const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) const [authFailed, authentication] = await Promise.allSettled([ pEvent(emitter, 'basic_auth:authentication_failed'), @@ -196,8 +168,40 @@ test.group('BasicAuth guard | authenticate', () => { assert.isFalse(basicAuthGuard.isAuthenticated) assert.throws(() => basicAuthGuard.getUserOrFail(), 'Invalid basic auth credentials') }) +}) - test('throw error when calling check after authenticate and user is not authenticated', async ({ +test.group('BasicAuth guard | check', () => { + test('check if user is logged in using check method', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults({ + password: await new Scrypt({}).make('secret'), + }) + + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) + + ctx.request.request.headers.authorization = `Basic ${Buffer.from( + `${user.email}:secret` + ).toString('base64')}` + + const [authSucceeded, state] = await Promise.all([ + pEvent(emitter, 'basic_auth:authentication_succeeded'), + basicAuthGuard.check(), + ]) + + assert.isTrue(state) + expectTypeOf(basicAuthGuard.authenticate).returns.toMatchTypeOf>() + assert.equal(authSucceeded!.user.id, user.id) + assert.equal(authSucceeded!.user.id, basicAuthGuard.getUserOrFail().id) + assert.equal(basicAuthGuard.getUserOrFail().id, user.id) + assert.isTrue(basicAuthGuard.isAuthenticated) + assert.isTrue(basicAuthGuard.authenticationAttempted) + }) + + test('throw error when calling authenticate after check and user is not authenticated', async ({ assert, }) => { const db = await createDatabase() @@ -208,7 +212,7 @@ test.group('BasicAuth guard | authenticate', () => { const user = await FactoryUser.createWithDefaults({ password: await new Scrypt({}).make('secret'), }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) ctx.request.request.headers.authorization = `Basic ${Buffer.from( `${user.email}:wrongpassword` @@ -234,7 +238,9 @@ test.group('BasicAuth guard | authenticate', () => { assert.isFalse(basicAuthGuard.isAuthenticated) assert.isUndefined(basicAuthGuard.user) }) +}) +test.group('BasicAuth guard | authenticateAsClient', () => { test('throw error when calling authenticateAsClient', async () => { const db = await createDatabase() await createTables(db) @@ -244,7 +250,7 @@ test.group('BasicAuth guard | authenticate', () => { password: await new Scrypt({}).make('secret'), }) const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx).setEmitter(emitter) + const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) await basicAuthGuard.authenticateAsClient(user) }).throws('Cannot authenticate as a client when using basic auth') }) diff --git a/tests/guards/session/attempt.spec.ts b/tests/guards/session/attempt.spec.ts index 8516b90..97a2a6a 100644 --- a/tests/guards/session/attempt.spec.ts +++ b/tests/guards/session/attempt.spec.ts @@ -13,7 +13,7 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' test.group('Session guard | attempt', () => { diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts index bd1c102..a1124f9 100644 --- a/tests/guards/session/authenticate.spec.ts +++ b/tests/guards/session/authenticate.spec.ts @@ -12,7 +12,7 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' import { diff --git a/tests/guards/session/get_user.spec.ts b/tests/guards/session/get_user.spec.ts index 892b04d..c6bdeb0 100644 --- a/tests/guards/session/get_user.spec.ts +++ b/tests/guards/session/get_user.spec.ts @@ -13,7 +13,7 @@ import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { createTables, createDatabase, createEmitter } from '../../helpers.js' import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' test.group('Session guard | getUser', () => { test('get user when authentication succeeded', async ({ assert, expectTypeOf }) => { diff --git a/tests/guards/session/login.spec.ts b/tests/guards/session/login.spec.ts index c9d7f66..2ad2706 100644 --- a/tests/guards/session/login.spec.ts +++ b/tests/guards/session/login.spec.ts @@ -13,7 +13,7 @@ import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' import { createDatabase, createEmitter, createTables, pEvent, parseCookies } from '../../helpers.js' diff --git a/tests/guards/session/login_via_id.spec.ts b/tests/guards/session/login_via_id.spec.ts index d8d4504..5452eec 100644 --- a/tests/guards/session/login_via_id.spec.ts +++ b/tests/guards/session/login_via_id.spec.ts @@ -13,7 +13,7 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' test.group('Session guard | loginViaId', () => { diff --git a/tests/guards/session/logout.spec.ts b/tests/guards/session/logout.spec.ts index ad82324..f2e0a15 100644 --- a/tests/guards/session/logout.spec.ts +++ b/tests/guards/session/logout.spec.ts @@ -13,7 +13,7 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' -import { SessionGuardFactory } from '../../../factories/session_guard_factory.js' +import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' import { pEvent, createTables, diff --git a/tests/helpers.ts b/tests/helpers.ts index 14e39c4..09fcc9d 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -24,7 +24,7 @@ import { LoggerFactory } from '@adonisjs/core/factories/logger' import { EncryptionFactory } from '@adonisjs/core/factories/encryption' import { SessionGuardEvents } from '../src/guards/session/types.js' -import { FactoryUser } from '../factories/lucid_user_provider.js' +import { FactoryUser } from '../factories/core/lucid_user_provider.js' import { BasicAuthGuardEvents } from '../src/guards/basic_auth/types.js' export const encryption: Encryption = new EncryptionFactory().create() @@ -83,9 +83,16 @@ export async function createTables(db: Database) { test.cleanup(async () => { await db.connection().schema.dropTable('users') + await db.connection().schema.dropTable('test_tokens') await db.connection().schema.dropTable('remember_me_tokens') }) + await db.connection().schema.createTable('test_tokens', (table) => { + table.string('series', 60).notNullable() + table.integer('user_id').notNullable().unsigned() + table.string('hash', 80).notNullable() + }) + await db.connection().schema.createTable('users', (table) => { table.increments() table.string('username').unique().notNullable() @@ -97,6 +104,7 @@ export async function createTables(db: Database) { table.string('series', 60).notNullable() table.integer('user_id').notNullable().unsigned() table.string('type').notNullable() + table.string('guard').notNullable() table.string('token', 80).notNullable() table.datetime('created_at').notNullable() table.datetime('updated_at').notNullable() @@ -185,6 +193,9 @@ export function defineCookies( .join(';') } +/** + * Travels time by seconds + */ export function timeTravel(secondsToTravel: number) { const test = getActiveTest() if (!test) { @@ -201,3 +212,22 @@ export function timeTravel(secondsToTravel: number) { timekeeper.reset() }) } + +/** + * Freezes time in the moment + */ +export function freezeTime() { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "freezeTime" outside of a Japa test') + } + + timekeeper.reset() + + const date = new Date() + timekeeper.freeze(date) + + test.cleanup(() => { + timekeeper.reset() + }) +} From 8cfd777f3739e768bfc37557e7a57112fe19fbe7 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 18:07:04 +0530 Subject: [PATCH 57/96] refactor: temporary remove opaque tokens --- src/guards/opaque_tokens/token.ts | 75 ------------------- .../opaque_tokens/token_providers/redis.ts | 52 ------------- 2 files changed, 127 deletions(-) delete mode 100644 src/guards/opaque_tokens/token.ts delete mode 100644 src/guards/opaque_tokens/token_providers/redis.ts diff --git a/src/guards/opaque_tokens/token.ts b/src/guards/opaque_tokens/token.ts deleted file mode 100644 index 50a5069..0000000 --- a/src/guards/opaque_tokens/token.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Token } from '../../core/token.js' - -/** - * Opaque tokens are generated to authenticate users via stateless tokens. - */ -export class OpaqueToken extends Token { - /** - * Static name for the token to uniquely identify a - * bucket of tokens - */ - readonly type: 'opaque_token' = 'opaque_token' - - /** - * Timestamp at which the token will expire - */ - declare expiresAt: Date - - /** - * The guard for which the opaque token was generated - */ - declare guard: string - - constructor( - /** - * Reference to the user id for whom the token - * is generated - */ - public userId: string | number, - - /** - * Series is a random value stored inside the database as it is. - * The series is generated via the seed method - */ - public series: string, - - /** - * Value is a random value only available at the time of issuing - * the token. Afterwards, the value is undefined. - */ - public value: string | undefined, - - /** - * Hash reference to the token hash - */ - public hash: string - ) { - super(series, value, hash) - } - - /** - * Create an opaque token for a user - */ - static create( - userId: string | number, - expiry: string | number, - guard: string, - size?: number - ): OpaqueToken { - const { series, value, hash } = this.seed(size) - const token = new OpaqueToken(userId, series, value, hash) - token.guard = guard - token.setExpiry(expiry) - - return token - } -} diff --git a/src/guards/opaque_tokens/token_providers/redis.ts b/src/guards/opaque_tokens/token_providers/redis.ts deleted file mode 100644 index 2669208..0000000 --- a/src/guards/opaque_tokens/token_providers/redis.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { Connection } from '@adonisjs/redis/types' - -import { OpaqueToken } from '../token.js' -import type { TokenProviderContract } from '../../../core/types.js' - -export class RedisOpaqueTokenProvider implements TokenProviderContract { - constructor(protected redisConnection: Connection) {} - - /** - * Persists the opaque token inside the redis database - */ - async createToken(token: OpaqueToken): Promise { - const key = token.series - const value = JSON.stringify({ - guard: token.guard, - user_id: token.userId, - hash: token.hash, - meta_data: token.metaData, - }) - - const ttl = Math.ceil(Math.abs(token.expiresAt.getTime() - new Date().getTime()) / 1000) - await this.redisConnection.setex(key, ttl, value) - } - - /** - * Finds a token by series inside the redis database and returns an - * instance of it. - * - * Returns null if the token is missing or expired - */ - async getTokenBySeries(series: string): Promise { - const value = await this.redisConnection.get(series) - if (!value) { - return null - } - - const token = JSON.parse(value) - const opaqueToken = new OpaqueToken(token.user_id, series, undefined, token.hash) - opaqueToken.metaData = token.meta_data - opaqueToken.guard = token.guard - return opaqueToken - } -} From 64d26dd7048c2cbde4efdde7e081af2fbb56b012 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 18:27:39 +0530 Subject: [PATCH 58/96] refactor: improve authenticator api to be more reliable --- src/auth/authenticator.ts | 58 ++++++++++++++++++++++++++------ tests/auth/auth_manager.spec.ts | 18 ++++++++++ tests/auth/authenticator.spec.ts | 58 +++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/auth/authenticator.ts b/src/auth/authenticator.ts index e892a15..196b643 100644 --- a/src/auth/authenticator.ts +++ b/src/auth/authenticator.ts @@ -18,6 +18,12 @@ import { AuthenticationException } from './errors.js' * guards to login users and authenticate requests. */ export class Authenticator> { + /** + * Name of the guard using which the authentication was last + * attempted. + */ + #authenticationAttemptedViaGuard?: keyof KnownGuards + /** * Name of the guard using which the request has * been authenticated @@ -59,27 +65,45 @@ export class Authenticator> { /** * A boolean to know if the current request has - * been authenticated + * been authenticated. The property returns false + * when "authenticate" or "authenticateUsing" methods + * are not used */ get isAuthenticated(): boolean { - return this.use(this.#authenticatedViaGuard || this.defaultGuard).isAuthenticated + if (!this.#authenticationAttemptedViaGuard) { + return false + } + + return this.use(this.#authenticationAttemptedViaGuard).isAuthenticated } /** - * Reference to the currently authenticated user + * Reference to the currently authenticated user. The property + * returns undefined when "authenticate" or "authenticateUsing" + * methods are not used. */ get user(): { [K in keyof KnownGuards]: ReturnType['user'] }[keyof KnownGuards] { - return this.use(this.#authenticatedViaGuard || this.defaultGuard).user + if (!this.#authenticationAttemptedViaGuard) { + return undefined + } + + return this.use(this.#authenticationAttemptedViaGuard).user } /** - * Whether or not the authentication has been attempted - * during the current request + * Whether or not the authentication has been attempted during + * the current request. The property returns false + * when "authenticate" or "authenticateUsing" methods + * are not used */ get authenticationAttempted(): boolean { - return this.use(this.#authenticatedViaGuard || this.defaultGuard).authenticationAttempted + if (!this.#authenticationAttemptedViaGuard) { + return false + } + + return this.use(this.#authenticationAttemptedViaGuard).authenticationAttempted } constructor(ctx: HttpContext, config: { default: keyof KnownGuards; guards: KnownGuards }) { @@ -95,7 +119,11 @@ export class Authenticator> { getUserOrFail(): { [K in keyof KnownGuards]: ReturnType['getUserOrFail']> }[keyof KnownGuards] { - return this.use(this.#authenticatedViaGuard || this.defaultGuard).getUserOrFail() as { + if (!this.#authenticatedViaGuard) { + throw AuthenticationException.E_INVALID_AUTH_SESSION() + } + + return this.use(this.#authenticatedViaGuard).getUserOrFail() as { [K in keyof KnownGuards]: ReturnType['getUserOrFail']> }[keyof KnownGuards] } @@ -128,6 +156,13 @@ export class Authenticator> { return guardInstance as ReturnType } + /** + * Authenticate current request using the default guard + */ + authenticate() { + return this.authenticateUsing() + } + /** * Authenticate the request using all of the mentioned * guards or the default guard. @@ -140,12 +175,13 @@ export class Authenticator> { */ async authenticateUsing(guards?: (keyof KnownGuards)[], options?: { loginRoute?: string }) { const guardsToUse = guards || [this.defaultGuard] - let lastUsedGuardDriver: string | undefined + let lastUsedDriver: string | undefined for (let guardName of guardsToUse) { debug('attempting to authenticate using guard "%s"', guardName) const guard = this.use(guardName) - lastUsedGuardDriver = guard.driverName + this.#authenticationAttemptedViaGuard = guardName + lastUsedDriver = guard.driverName if (await guard.check()) { this.#authenticatedViaGuard = guardName @@ -155,7 +191,7 @@ export class Authenticator> { throw new AuthenticationException('Unauthorized access', { code: 'E_UNAUTHORIZED_ACCESS', - guardDriverName: lastUsedGuardDriver!, + guardDriverName: lastUsedDriver!, redirectTo: options?.loginRoute, }) } diff --git a/tests/auth/auth_manager.spec.ts b/tests/auth/auth_manager.spec.ts index 2a3011f..a8ed3e4 100644 --- a/tests/auth/auth_manager.spec.ts +++ b/tests/auth/auth_manager.spec.ts @@ -14,6 +14,7 @@ import { createEmitter } from '../helpers.js' import { AuthManager } from '../../src/auth/auth_manager.js' import { Authenticator } from '../../src/auth/authenticator.js' import { SessionGuardFactory } from '../../factories/guards/session/guard_factory.js' +import { AuthenticatorClient } from '../../src/auth/authenticator_client.js' test.group('Auth manager', () => { test('create authenticator from auth manager', async ({ assert, expectTypeOf }) => { @@ -32,4 +33,21 @@ test.group('Auth manager', () => { assert.instanceOf(authManager.createAuthenticator(ctx), Authenticator) expectTypeOf(authManager.createAuthenticator(ctx).use).parameters.toMatchTypeOf<['web'?]>() }) + + test('create authenticator client from auth manager', async ({ assert, expectTypeOf }) => { + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) + + const authManager = new AuthManager({ + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + assert.equal(authManager.defaultGuard, 'web') + assert.instanceOf(authManager.createAuthenticatorClient(), AuthenticatorClient) + expectTypeOf(authManager.createAuthenticatorClient().use).parameters.toMatchTypeOf<['web'?]>() + }) }) diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index 472678d..eafc225 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -72,7 +72,7 @@ test.group('Authenticator', () => { await sessionMiddleware.handle(ctx, async () => { ctx.session.put('auth_web', user.id) - await authenticator.authenticateUsing() + await authenticator.authenticate() }) assert.instanceOf(authenticator.user, FactoryUser) @@ -84,6 +84,62 @@ test.group('Authenticator', () => { assert.isTrue(authenticator.authenticationAttempted) }) + test('authenticate using the guard instance', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + await sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + await authenticator.use().authenticate() + }) + + assert.isUndefined(authenticator.user) + assert.isUndefined(authenticator.authenticatedViaGuard) + assert.isFalse(authenticator.isAuthenticated) + assert.isFalse(authenticator.authenticationAttempted) + }) + + test('access properties without authenticating user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const emitter = createEmitter() + const ctx = new HttpContextFactory().create() + const user = await FactoryUser.createWithDefaults() + const sessionGuard = new SessionGuardFactory().create(ctx, emitter) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => sessionGuard, + }, + }) + + await sessionMiddleware.handle(ctx, async () => { + ctx.session.put('auth_web', user.id) + }) + + assert.isUndefined(authenticator.user) + assert.isUndefined(authenticator.authenticatedViaGuard) + assert.isFalse(authenticator.isAuthenticated) + assert.isFalse(authenticator.authenticationAttempted) + assert.throws(() => authenticator.getUserOrFail(), 'Invalid or expired authentication session') + }) + test('throw error when unable to authenticate', async ({ assert }) => { assert.plan(4) From 574885ef880681c2b55839836d30af5470f809c1 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 18:51:08 +0530 Subject: [PATCH 59/96] refactor: remove database user provider --- factories/core/database_user_provider.ts | 36 ---- src/auth/define_config.ts | 23 +-- src/auth/user_providers/main.ts | 9 - src/core/types.ts | 38 ---- src/core/user_providers/database.ts | 162 ------------------ tests/auth/define_config.spec.ts | 19 +- .../database/create_user_for_guard.spec.ts | 56 ------ .../database/find_by_id.spec.ts | 44 ----- .../database/find_by_uid.spec.ts | 54 ------ .../database/verify_credentials.spec.ts | 110 ------------ 10 files changed, 4 insertions(+), 547 deletions(-) delete mode 100644 factories/core/database_user_provider.ts delete mode 100644 src/core/user_providers/database.ts delete mode 100644 tests/core/user_providers/database/create_user_for_guard.spec.ts delete mode 100644 tests/core/user_providers/database/find_by_id.spec.ts delete mode 100644 tests/core/user_providers/database/find_by_uid.spec.ts delete mode 100644 tests/core/user_providers/database/verify_credentials.spec.ts diff --git a/factories/core/database_user_provider.ts b/factories/core/database_user_provider.ts deleted file mode 100644 index 13347a6..0000000 --- a/factories/core/database_user_provider.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Hash } from '@adonisjs/core/hash' -import type { Database } from '@adonisjs/lucid/database' -import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' -import { BaseDatabaseUserProvider } from '../../src/core/user_providers/database.js' - -/** - * Representation of a test database user provider extending - * the base abstract provider. - */ -export class TestDatabaseUserProvider< - RealUser extends Record, -> extends BaseDatabaseUserProvider {} - -/** - * Creates an instance of the DatabaseUserProvider with sane - * defaults for testing - */ -export class DatabaseUserProviderFactory { - create(db: Database) { - return new TestDatabaseUserProvider(db, new Hash(new Scrypt({})), { - id: 'id', - table: 'users', - passwordColumnName: 'password', - uids: ['email', 'username'], - }) - } -} diff --git a/src/auth/define_config.ts b/src/auth/define_config.ts index 94166a5..c7a491f 100644 --- a/src/auth/define_config.ts +++ b/src/auth/define_config.ts @@ -13,12 +13,8 @@ import { configProvider } from '@adonisjs/core' import type { ConfigProvider } from '@adonisjs/core/types' import type { GuardConfigProvider, GuardFactory } from './types.js' -import type { LucidUserProvider, DatabaseUserProvider } from './user_providers/main.js' -import type { - LucidAuthenticatable, - LucidUserProviderOptions, - DatabaseUserProviderOptions, -} from '../core/types.js' +import type { LucidUserProvider } from './user_providers/main.js' +import type { LucidAuthenticatable, LucidUserProviderOptions } from '../core/types.js' /** * Config resolved by the "defineConfig" method @@ -70,25 +66,10 @@ export function defineConfig< * finding users for authentication */ export const providers: { - db: >( - config: DatabaseUserProviderOptions - ) => ConfigProvider> lucid: ( config: LucidUserProviderOptions ) => ConfigProvider> } = { - db(config) { - return configProvider.create(async (app) => { - const db = await app.container.make('lucid.db') - const hasher = await app.container.make('hash') - const { DatabaseUserProvider } = await import('./user_providers/main.js') - return new DatabaseUserProvider( - db, - config.hasher ? hasher.use(config.hasher) : hasher.use(), - config - ) - }) - }, lucid(config) { return configProvider.create(async (app) => { const { LucidUserProvider } = await import('./user_providers/main.js') diff --git a/src/auth/user_providers/main.ts b/src/auth/user_providers/main.ts index 0653ff3..82c2d22 100644 --- a/src/auth/user_providers/main.ts +++ b/src/auth/user_providers/main.ts @@ -8,7 +8,6 @@ */ import { BaseLucidUserProvider } from '../../core/user_providers/lucid.js' -import { BaseDatabaseUserProvider } from '../../core/user_providers/database.js' import type { LucidAuthenticatable, UserProviderContract } from '../../core/types.js' /** @@ -18,11 +17,3 @@ import type { LucidAuthenticatable, UserProviderContract } from '../../core/type export class LucidUserProvider extends BaseLucidUserProvider implements UserProviderContract> {} - -/** - * Using database query builder to find users for - * session auth - */ -export class DatabaseUserProvider> - extends BaseDatabaseUserProvider - implements UserProviderContract {} diff --git a/src/core/types.ts b/src/core/types.ts index 46b5f1c..e187159 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -123,44 +123,6 @@ export type LucidUserProviderOptions = { uids: Extract, string>[] } -/** - * Options accepted by the Database user provider - */ -export type DatabaseUserProviderOptions> = { - /** - * Define the hasher to use to hash and verify - * passwords - */ - hasher?: keyof HashersList - - /** - * Optionally define the connection to use when making database - * queries - */ - connection?: string - - /** - * Database table to query to find the user - */ - table: string - - /** - * Column name to read the hashed password - */ - passwordColumnName: string - - /** - * An array of uids to use when finding a user for login. Make - * sure all fields can be used to uniquely lookup a user. - */ - uids: Extract[] - - /** - * The name of the id column to unique identify the user. - */ - id: string -} - /** * Options accepted by the Database token provider */ diff --git a/src/core/user_providers/database.ts b/src/core/user_providers/database.ts deleted file mode 100644 index a562e33..0000000 --- a/src/core/user_providers/database.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { Hash } from '@adonisjs/core/hash' -import { RuntimeException } from '@poppinss/utils' -import type { Database } from '@adonisjs/lucid/database' - -import debug from '../../auth/debug.js' -import { GuardUser } from '../guard_user.js' -import { PROVIDER_REAL_USER } from '../../auth/symbols.js' -import type { DatabaseUserProviderOptions, UserProviderContract } from '../types.js' - -/** - * Database user represents a guard user used by authentication guards - * to perform authentication. - */ -class DatabaseUser> extends GuardUser { - #options: { id: string } - - constructor(realUser: RealUser, options: { id: string }) { - super(realUser) - this.#options = options - } - - /** - * @inheritdoc - */ - getId(): string | number { - const id = this.realUser[this.#options.id] - - if (!id) { - throw new RuntimeException( - `Invalid user object. The value of column "${this.#options.id}" is undefined or null` - ) - } - - return id - } -} - -/** - * Database user provider is used to lookup user for authentication - * using the Database query builder. - */ -export abstract class BaseDatabaseUserProvider> - implements UserProviderContract -{ - declare [PROVIDER_REAL_USER]: RealUser - - constructor( - /** - * Reference to the database query builder needed to - * query the database for users - */ - protected db: Database, - - /** - * Hasher is used to verify plain text passwords - */ - protected hasher: Hash, - - /** - * Options accepted - */ - protected options: DatabaseUserProviderOptions - ) { - debug('db_user_provider: options %O', options) - } - - /** - * Returns an instance of the query builder - */ - protected getQueryBuilder() { - return this.db.connection(this.options.connection).query() - } - - /** - * Returns an instance of the "DatabaseUser" that guards - * can use for authentication - */ - async createUserForGuard(user: RealUser) { - if (!user || typeof user !== 'object') { - throw new RuntimeException( - `Invalid user object. It must be a database row object from the "${this.options.table}" table` - ) - } - - debug('db_user_provider: converting user object to guard user %O', user) - return new DatabaseUser(user, this.options) - } - - /** - * Finds a user by id by query the configured database - * table - */ - async findById(value: string | number): Promise | null> { - const query = this.getQueryBuilder().from(this.options.table) - debug('db_user_provider: finding user by id %s', value) - - const user = await query.where(this.options.id, value).limit(1).first() - if (!user) { - return null - } - - return this.createUserForGuard(user) - } - - /** - * Finds a user using one of the pre-configured unique - * ids, via the configured model. - */ - async findByUid(value: string | number): Promise | null> { - const query = this.getQueryBuilder().from(this.options.table) - this.options.uids.forEach((uid) => query.orWhere(uid, value)) - - debug('db_user_provider: finding user by uids, uids: %O, value: %s', this.options.uids, value) - - const user = await query.limit(1).first() - if (!user) { - return null - } - - return this.createUserForGuard(user) - } - - /** - * Find a user by uid and verify their password. This method prevents - * timing attacks. - */ - async verifyCredentials( - uid: string | number, - password: string - ): Promise | null> { - const user = await this.findByUid(uid) - - if (user) { - const passwordHash = user.getOriginal()[this.options.passwordColumnName] - if (!passwordHash) { - throw new RuntimeException( - `Cannot verify password during login. The value of column "${this.options.passwordColumnName}" is undefined or null` - ) - } - - if (await this.hasher.verify(passwordHash, password)) { - return user - } - return null - } - - /** - * Hashing the password to prevent timing attacks. - */ - await this.hasher.make(password) - return null - } -} diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts index 67c4312..be3976c 100644 --- a/tests/auth/define_config.spec.ts +++ b/tests/auth/define_config.spec.ts @@ -13,13 +13,13 @@ import { AppFactory } from '@adonisjs/core/factories/app' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { HashManagerFactory } from '@adonisjs/core/factories/hash' -import { createDatabase, createEmitter } from '../helpers.js' +import { createEmitter } from '../helpers.js' import { AuthManager } from '../../src/auth/auth_manager.js' import { Authenticator } from '../../src/auth/authenticator.js' import { FactoryUser } from '../../factories/core/lucid_user_provider.js' import { sessionGuard } from '../../src/guards/session/define_config.js' import { defineConfig, providers } from '../../src/auth/define_config.js' -import { DatabaseUserProvider, LucidUserProvider } from '../../src/auth/user_providers/main.js' +import { LucidUserProvider } from '../../src/auth/user_providers/main.js' const BASE_URL = new URL('./', import.meta.url) const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService @@ -42,21 +42,6 @@ test.group('Define config | providers', () => { const lucidProvider = await lucidConfigProvider.resolver(app) assert.instanceOf(lucidProvider, LucidUserProvider) }) - - test('configure db provider', async ({ assert }) => { - const dbConfigProvider = providers.db({ - table: 'users', - id: 'id', - passwordColumnName: 'password', - uids: ['email'], - }) - - app.container.bind('lucid.db', () => createDatabase()) - app.container.bind('hash', () => new HashManagerFactory().create()) - - const dbProvider = await dbConfigProvider.resolver(app) - assert.instanceOf(dbProvider, DatabaseUserProvider) - }) }) test.group('Define config', () => { diff --git a/tests/core/user_providers/database/create_user_for_guard.spec.ts b/tests/core/user_providers/database/create_user_for_guard.spec.ts deleted file mode 100644 index e731909..0000000 --- a/tests/core/user_providers/database/create_user_for_guard.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' -import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' - -test.group('Database user provider | createUserForGuard', () => { - test('create a guard user from database row', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const { id } = await FactoryUser.createWithDefaults() - const user = await db.connection().from('users').where('id', id).first() - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const providerUser = await dbUserProvider.createUserForGuard(user) - - expectTypeOf(providerUser.getOriginal()).toMatchTypeOf() - assert.equal(providerUser.getId(), 1) - assert.deepEqual(providerUser.getOriginal(), { - id: 1, - email: 'foo@bar.com', - username: 'foo', - password: 'secret', - }) - }) - - test('return error when user value is not an object', async () => { - const db = await createDatabase() - await createTables(db) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - await dbUserProvider.createUserForGuard(null as any) - }).throws('Invalid user object. It must be a database row object from the "users" table') - - test('return error when value primaryColumn is missing', async () => { - const db = await createDatabase() - await createTables(db) - - const { id } = await FactoryUser.createWithDefaults() - const user = await db.connection().from('users').where('id', id).first() - delete user.id - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const providerUser = await dbUserProvider.createUserForGuard(user) - providerUser.getId() - }).throws('Invalid user object. The value of column "id" is undefined or null') -}) diff --git a/tests/core/user_providers/database/find_by_id.spec.ts b/tests/core/user_providers/database/find_by_id.spec.ts deleted file mode 100644 index 5ce01fb..0000000 --- a/tests/core/user_providers/database/find_by_id.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' -import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' - -test.group('Database user provider | findById', () => { - test('find a user using primary key', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults() - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const userById = await dbUserProvider.findById(1) - - expectTypeOf(userById!.getOriginal()).toMatchTypeOf() - assert.deepEqual(userById!.getOriginal(), { - id: 1, - email: 'foo@bar.com', - username: 'foo', - password: 'secret', - }) - assert.equal(userById!.getId(), 1) - }) - - test('return null when unable to find user by id', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const userById = await dbUserProvider.findById(1) - - assert.isNull(userById) - }) -}) diff --git a/tests/core/user_providers/database/find_by_uid.spec.ts b/tests/core/user_providers/database/find_by_uid.spec.ts deleted file mode 100644 index 1ba3d04..0000000 --- a/tests/core/user_providers/database/find_by_uid.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables } from '../../../helpers.js' -import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' -import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' - -test.group('Database user provider | findByUId', () => { - test('find a user using primary key', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults() - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const userByUsername = await dbUserProvider.findByUid('foo') - const userByEmail = await dbUserProvider.findByUid('foo@bar.com') - - expectTypeOf(userByUsername!.getOriginal()).toMatchTypeOf() - assert.equal(userByUsername!.getId(), 1) - assert.deepEqual(userByUsername!.getOriginal(), { - id: 1, - email: 'foo@bar.com', - username: 'foo', - password: 'secret', - }) - - expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf() - assert.equal(userByEmail!.getId(), 1) - assert.deepEqual(userByEmail!.getOriginal(), { - id: 1, - email: 'foo@bar.com', - username: 'foo', - password: 'secret', - }) - }) - - test('return null when unable to find user by uid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - - assert.isNull(await dbUserProvider.findByUid('foo@bar.com')) - assert.isNull(await dbUserProvider.findByUid('foo')) - }) -}) diff --git a/tests/core/user_providers/database/verify_credentials.spec.ts b/tests/core/user_providers/database/verify_credentials.spec.ts deleted file mode 100644 index cdb55f9..0000000 --- a/tests/core/user_providers/database/verify_credentials.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import convertHrtime from 'convert-hrtime' -import { FactoryUser } from '../../../../factories/core/lucid_user_provider.js' -import { createDatabase, createTables, getHasher } from '../../../helpers.js' -import { DatabaseUserProviderFactory } from '../../../../factories/core/database_user_provider.js' - -test.group('Database user provider | verifyCredentials', () => { - test('return user when email and password are correct', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const userByEmail = await dbUserProvider.verifyCredentials('foo@bar.com', 'secret') - - expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf() - assert.equal(userByEmail!.getId(), 1) - assert.containsSubset(userByEmail!.getOriginal(), { - id: 1, - email: 'foo@bar.com', - username: 'foo', - }) - }) - - test('return null when password is invalid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const userByEmail = await dbUserProvider.verifyCredentials('foo@bar.com', 'supersecret') - assert.isNull(userByEmail) - }) - - test('return null when email is incorrect', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - const userByEmail = await dbUserProvider.verifyCredentials('bar@bar.com', 'secret') - assert.isNull(userByEmail) - }) - - test('throw error when password is missing', async () => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - email: 'foo@bar.com', - username: 'foo', - password: null, - }) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - await dbUserProvider.verifyCredentials('foo@bar.com', 'secret') - }).throws( - 'Cannot verify password during login. The value of column "password" is undefined or null' - ) - - test('prevent timing attacks when email or password are invalid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.createWithDefaults({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const dbUserProvider = new DatabaseUserProviderFactory().create(db) - let startTime = process.hrtime.bigint() - await dbUserProvider.verifyCredentials('baz@bar.com', 'secret') - const invalidEmailTime = convertHrtime(process.hrtime.bigint() - startTime) - - startTime = process.hrtime.bigint() - await dbUserProvider.verifyCredentials('foo@bar.com', 'supersecret') - const invalidPasswordTime = convertHrtime(process.hrtime.bigint() - startTime) - - /** - * Same timing within the range of 10 milliseconds is acceptable - */ - assert.isBelow(Math.abs(invalidPasswordTime.seconds - invalidEmailTime.seconds), 1) - assert.isBelow(Math.abs(invalidPasswordTime.milliseconds - invalidEmailTime.milliseconds), 10) - }) -}) From 24a555bb133d43b1edd368ba315912a19f203927 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 19:08:10 +0530 Subject: [PATCH 60/96] feat: add getUserForAuth hook in lucid provider --- src/core/types.ts | 9 ++- src/core/user_providers/lucid.ts | 22 ++++++- .../user_providers/lucid/find_by_uid.spec.ts | 65 +++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index e187159..8b672e2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -12,7 +12,7 @@ import type { QueryClientContract } from '@adonisjs/lucid/types/database' import type { GuardUser } from './guard_user.js' import type { PROVIDER_REAL_USER } from '../auth/symbols.js' -import type { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' +import type { LucidModel } from '@adonisjs/lucid/types/model' /** * The UserProvider is used to lookup a user for authentication @@ -77,8 +77,11 @@ export interface TokenProviderContract { * A lucid model that can be used during authentication */ export type LucidAuthenticatable = LucidModel & { - // new (): LucidRow & {} - new (): LucidRow + /** + * Optional static method to customize the user lookup + * during "findByUid" method call. + */ + getUserForAuth?(uids: string[], value: string | number): Promise } /** diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts index 5130324..416712e 100644 --- a/src/core/user_providers/lucid.ts +++ b/src/core/user_providers/lucid.ts @@ -139,7 +139,27 @@ export abstract class BaseLucidUserProvider> | null> { - const query = this.getQueryBuilder(await this.getModel()) + const model = await this.getModel() + + /** + * Use custom lookup method when defined on the + * model. + */ + if ('getUserForAuth' in model && typeof model.getUserForAuth === 'function') { + debug('lucid_user_provider: using getUserForAuth method on "[class %s]"', model.name) + + const user = await model.getUserForAuth(this.options.uids, value) + if (!user) { + return null + } + + return new LucidUser(user) + } + + /** + * Self query + */ + const query = this.getQueryBuilder(model) this.options.uids.forEach((uid) => query.orWhere(uid, value)) debug( diff --git a/tests/core/user_providers/lucid/find_by_uid.spec.ts b/tests/core/user_providers/lucid/find_by_uid.spec.ts index dce1279..64d8098 100644 --- a/tests/core/user_providers/lucid/find_by_uid.spec.ts +++ b/tests/core/user_providers/lucid/find_by_uid.spec.ts @@ -36,6 +36,71 @@ test.group('Lucid user provider | findByUid', () => { assert.equal(userByUsername!.getId(), 1) }) + test('customize user lookup by defining "getUserForAuth" method', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class CustomUser extends FactoryUser { + static async getUserForAuth(uids: string[], value: number | string) { + assert.deepEqual(uids, ['email']) + assert.equal(value, 'foo@bar.com') + return null + } + } + + const lucidUserProvider = new LucidUserProviderFactory().createForModel({ + model: async () => { + return { + default: CustomUser, + } + }, + passwordColumnName: 'password', + uids: ['email'], + }) + + const userByEmail = await lucidUserProvider.findByUid('foo@bar.com') + assert.isNull(userByEmail) + }) + + test('create provider user from value returned by "getUserForAuth"', async ({ + assert, + expectTypeOf, + }) => { + const db = await createDatabase() + await createTables(db) + + class CustomUser extends FactoryUser { + static async getUserForAuth(uids: string[], value: number | string) { + const user = await this.query().where(uids[0], value).first() + return user + } + } + + const lucidUserProvider = new LucidUserProviderFactory().createForModel({ + model: async () => { + return { + default: CustomUser, + } + }, + passwordColumnName: 'password', + uids: ['email'], + }) + await CustomUser.create({ email: 'foo@bar.com', username: 'foo', password: 'secret' }) + + const userByEmail = await lucidUserProvider.findByUid('foo@bar.com') + const userByUsername = await lucidUserProvider.findByUid('foo@bar.com') + + expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf>() + assert.instanceOf(userByEmail!.getOriginal(), FactoryUser) + assert.isFalse(userByEmail!.getOriginal().$isNew) + assert.equal(userByEmail!.getId(), 1) + + expectTypeOf(userByUsername!.getOriginal()).toMatchTypeOf>() + assert.instanceOf(userByUsername!.getOriginal(), FactoryUser) + assert.isFalse(userByUsername!.getOriginal().$isNew) + assert.equal(userByUsername!.getId(), 1) + }) + test('return null when unable to find user by uid', async ({ assert }) => { const db = await createDatabase() await createTables(db) From f5c83c0ced6694a3a433915e2f5840ee5f8c6064 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 11 Jan 2024 22:48:23 +0530 Subject: [PATCH 61/96] refactor: cleanup core workings of auth In this commit we remove cross boundary communication between the core of auth package and guards. --- .gitignore | 1 + factories/auth/main.ts | 58 ++ factories/core/database_token_factory.ts | 41 -- factories/core/lucid_user_provider.ts | 90 --- factories/guards/basic_auth/guard_factory.ts | 45 -- factories/guards/basic_auth/main.ts | 10 - .../database_remember_token_factory.ts | 22 - factories/guards/session/guard_factory.ts | 55 -- factories/guards/session/main.ts | 11 - index.ts | 11 +- package.json | 16 +- providers/auth_provider.ts | 4 +- services/auth.ts | 2 +- src/auth/errors.ts | 244 ------- src/auth/user_providers/main.ts | 19 - src/{auth => }/auth_manager.ts | 6 +- src/{auth => }/authenticator.ts | 113 ++-- src/{auth => }/authenticator_client.ts | 12 +- src/core/README.md | 6 - src/core/guard_user.ts | 36 - src/core/token_providers/database.ts | 121 ---- src/core/types.ts | 143 ---- src/core/user_providers/lucid.ts | 208 ------ src/{auth => }/debug.ts | 0 src/{auth => }/define_config.ts | 21 - src/errors.ts | 135 ++++ src/guards/basic_auth/define_config.ts | 49 -- src/guards/basic_auth/guard.ts | 212 ------ src/guards/basic_auth/main.ts | 11 - src/guards/basic_auth/types.ts | 53 -- src/guards/session/define_config.ts | 85 --- src/guards/session/guard.ts | 626 ------------------ src/guards/session/main.ts | 12 - src/guards/session/remember_me_token.ts | 164 ----- .../session/token_providers/database.ts | 83 --- src/guards/session/types.ts | 136 ---- .../middleware/initialize_auth_middleware.ts | 0 src/{auth => }/plugins/japa/api_client.ts | 11 +- src/{auth => }/plugins/japa/browser_client.ts | 17 +- src/{auth => }/symbols.ts | 0 src/{auth => }/types.ts | 58 +- tests/auth/auth_manager.spec.ts | 19 +- tests/auth/authenticator.spec.ts | 139 ++-- tests/auth/authenticator_client.spec.ts | 42 +- .../auth/{configure.spec.ts => configure.ts} | 0 tests/auth/define_config.spec.ts | 81 +-- tests/auth/errors.spec.ts | 193 ++---- tests/core/token_providers/database.spec.ts | 81 --- .../lucid/create_user_for_guard.spec.ts | 55 -- .../user_providers/lucid/find_by_id.spec.ts | 42 -- .../user_providers/lucid/find_by_uid.spec.ts | 113 ---- .../lucid/verify_credentials.spec.ts | 110 --- tests/guards/basic_auth/authenticate.spec.ts | 256 ------- tests/guards/session/attempt.spec.ts | 104 --- tests/guards/session/authenticate.spec.ts | 480 -------------- tests/guards/session/define_config.spec.ts | 87 --- tests/guards/session/get_user.spec.ts | 55 -- tests/guards/session/login.spec.ts | 142 ---- tests/guards/session/login_via_id.spec.ts | 69 -- tests/guards/session/logout.spec.ts | 161 ----- .../session/remember_me_db_provider.spec.ts | 86 --- .../guards/session/remember_me_token.spec.ts | 111 ---- tests/helpers.ts | 2 +- 63 files changed, 514 insertions(+), 4861 deletions(-) create mode 100644 factories/auth/main.ts delete mode 100644 factories/core/database_token_factory.ts delete mode 100644 factories/core/lucid_user_provider.ts delete mode 100644 factories/guards/basic_auth/guard_factory.ts delete mode 100644 factories/guards/basic_auth/main.ts delete mode 100644 factories/guards/session/database_remember_token_factory.ts delete mode 100644 factories/guards/session/guard_factory.ts delete mode 100644 factories/guards/session/main.ts delete mode 100644 src/auth/errors.ts delete mode 100644 src/auth/user_providers/main.ts rename src/{auth => }/auth_manager.ts (81%) rename src/{auth => }/authenticator.ts (64%) rename src/{auth => }/authenticator_client.ts (81%) delete mode 100644 src/core/README.md delete mode 100644 src/core/guard_user.ts delete mode 100644 src/core/token_providers/database.ts delete mode 100644 src/core/types.ts delete mode 100644 src/core/user_providers/lucid.ts rename src/{auth => }/debug.ts (100%) rename src/{auth => }/define_config.ts (69%) create mode 100644 src/errors.ts delete mode 100644 src/guards/basic_auth/define_config.ts delete mode 100644 src/guards/basic_auth/guard.ts delete mode 100644 src/guards/basic_auth/main.ts delete mode 100644 src/guards/basic_auth/types.ts delete mode 100644 src/guards/session/define_config.ts delete mode 100644 src/guards/session/guard.ts delete mode 100644 src/guards/session/main.ts delete mode 100644 src/guards/session/remember_me_token.ts delete mode 100644 src/guards/session/token_providers/database.ts delete mode 100644 src/guards/session/types.ts rename src/{auth => }/middleware/initialize_auth_middleware.ts (100%) rename src/{auth => }/plugins/japa/api_client.ts (92%) rename src/{auth => }/plugins/japa/browser_client.ts (88%) rename src/{auth => }/symbols.ts (100%) rename src/{auth => }/types.ts (73%) rename tests/auth/{configure.spec.ts => configure.ts} (100%) delete mode 100644 tests/core/token_providers/database.spec.ts delete mode 100644 tests/core/user_providers/lucid/create_user_for_guard.spec.ts delete mode 100644 tests/core/user_providers/lucid/find_by_id.spec.ts delete mode 100644 tests/core/user_providers/lucid/find_by_uid.spec.ts delete mode 100644 tests/core/user_providers/lucid/verify_credentials.spec.ts delete mode 100644 tests/guards/basic_auth/authenticate.spec.ts delete mode 100644 tests/guards/session/attempt.spec.ts delete mode 100644 tests/guards/session/authenticate.spec.ts delete mode 100644 tests/guards/session/define_config.spec.ts delete mode 100644 tests/guards/session/get_user.spec.ts delete mode 100644 tests/guards/session/login.spec.ts delete mode 100644 tests/guards/session/login_via_id.spec.ts delete mode 100644 tests/guards/session/logout.spec.ts delete mode 100644 tests/guards/session/remember_me_db_provider.spec.ts delete mode 100644 tests/guards/session/remember_me_token.spec.ts diff --git a/.gitignore b/.gitignore index 324fb8c..dc51177 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ yarn.lock shrinkwrap.yaml package-lock.json test/__app +backup diff --git a/factories/auth/main.ts b/factories/auth/main.ts new file mode 100644 index 0000000..d749189 --- /dev/null +++ b/factories/auth/main.ts @@ -0,0 +1,58 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { GUARD_KNOWN_EVENTS } from '../../src/symbols.js' +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import { AuthClientResponse, GuardContract } from '../../src/types.js' + +export type FakeUser = { + id: number +} +export class FakeGuard implements GuardContract { + isAuthenticated: boolean = false + authenticationAttempted: boolean = false + driverName: string = 'fake' + user?: FakeUser; + + declare [GUARD_KNOWN_EVENTS]: undefined + + getUserOrFail(): FakeUser { + if (!this.user) { + throw new E_UNAUTHORIZED_ACCESS('Unauthorized access', { guardDriverName: this.driverName }) + } + return this.user + } + + async authenticate(): Promise { + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + + this.authenticationAttempted = true + this.isAuthenticated = true + this.user = { + id: 1, + } + + return this.user + } + + async check(): Promise { + try { + await this.authenticate() + return true + } catch { + return false + } + } + + async authenticateAsClient(_: FakeUser): Promise { + throw new Error('Not supported') + } +} diff --git a/factories/core/database_token_factory.ts b/factories/core/database_token_factory.ts deleted file mode 100644 index f6472d0..0000000 --- a/factories/core/database_token_factory.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Database } from '@adonisjs/lucid/database' -import { DatabaseTokenProvider } from '../../src/core/token_providers/database.js' - -type TestToken = { - series: string - user_id: number - hash: string -} - -/** - * Test implementation of the database token provider - */ -export class TestDatabaseTokenProvider extends DatabaseTokenProvider { - protected prepareToken(dbRow: TestToken): TestToken { - return dbRow - } - - protected parseToken(token: TestToken): TestToken { - return token - } -} - -/** - * Creates instance of the TestDatabaseTokenProvider - */ -export class DatabaseTokenProviderFactory { - create(db: Database) { - return new TestDatabaseTokenProvider(db, { - table: 'test_tokens', - }) - } -} diff --git a/factories/core/lucid_user_provider.ts b/factories/core/lucid_user_provider.ts deleted file mode 100644 index 4742ddc..0000000 --- a/factories/core/lucid_user_provider.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Hash } from '@adonisjs/core/hash' -import { BaseModel, column } from '@adonisjs/lucid/orm' -import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' - -import { PROVIDER_REAL_USER } from '../../src/auth/symbols.js' -import { BaseLucidUserProvider } from '../../src/core/user_providers/lucid.js' -import type { LucidAuthenticatable, LucidUserProviderOptions } from '../../src/core/types.js' - -/** - * User model that writes to the users table. Used for testing - */ -export class FactoryUser extends BaseModel { - static table = 'users' - - static createWithDefaults(attributes?: { - email?: string - password?: string | null - username?: string - }) { - return this.create({ - email: 'foo@bar.com', - username: 'foo', - password: 'secret', - ...attributes, - }) - } - - @column() - declare id: number - - @column() - declare username: string - - @column() - declare email: string - - @column() - declare password: string | null -} - -/** - * User provider to read user data using the - * "FactoryUser" model - */ -export class TestLucidUserProvider< - UserModel extends LucidAuthenticatable, -> extends BaseLucidUserProvider { - declare [PROVIDER_REAL_USER]: InstanceType -} - -/** - * Creates an instance of the LucidUserProvider with sane - * defaults for testing - */ -export class LucidUserProviderFactory { - /** - * Creates instance of "TestLucidUserProvider" for a custom - * user model - */ - createForModel(options: LucidUserProviderOptions) { - return new TestLucidUserProvider(new Hash(new Scrypt({})), { - ...options, - }) - } - - /** - * Creates instance of "TestLucidUserProvider" for the "FactoryUser" - * model - */ - create() { - return this.createForModel({ - model: async () => { - return { - default: FactoryUser, - } - }, - passwordColumnName: 'password', - uids: ['email', 'username'], - }) - } -} diff --git a/factories/guards/basic_auth/guard_factory.ts b/factories/guards/basic_auth/guard_factory.ts deleted file mode 100644 index 6362fa8..0000000 --- a/factories/guards/basic_auth/guard_factory.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { HttpContext } from '@adonisjs/core/http' -import { EmitterLike } from '@adonisjs/core/types/events' -import { - FactoryUser, - TestLucidUserProvider, - LucidUserProviderFactory, -} from '../../core/lucid_user_provider.js' -import { PROVIDER_REAL_USER } from '../../../src/auth/symbols.js' -import type { UserProviderContract } from '../../../src/core/types.js' -import { BasicAuthGuard } from '../../../src/guards/basic_auth/guard.js' -import { BasicAuthGuardEvents } from '../../../src/guards/basic_auth/types.js' - -/** - * Exposes the API to create a basic auth guard for testing. Under - * the hood configures Lucid models for looking up users - */ -export class BasicAuthGuardFactory { - merge() { - return this - } - - create< - UserProvider extends UserProviderContract = TestLucidUserProvider, - >( - ctx: HttpContext, - emitter: EmitterLike>, - provider?: UserProvider - ) { - return new BasicAuthGuard( - 'basic', - ctx, - emitter, - provider || new LucidUserProviderFactory().create() - ) - } -} diff --git a/factories/guards/basic_auth/main.ts b/factories/guards/basic_auth/main.ts deleted file mode 100644 index cf06f27..0000000 --- a/factories/guards/basic_auth/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export { BasicAuthGuardFactory } from './guard_factory.js' diff --git a/factories/guards/session/database_remember_token_factory.ts b/factories/guards/session/database_remember_token_factory.ts deleted file mode 100644 index 44b1594..0000000 --- a/factories/guards/session/database_remember_token_factory.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Database } from '@adonisjs/lucid/database' -import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' - -/** - * Creates instance of the DatabaseRememberTokenProvider - */ -export class DatabaseRememberTokenFactory { - create(db: Database) { - return new DatabaseRememberTokenProvider(db, { - table: 'remember_me_tokens', - }) - } -} diff --git a/factories/guards/session/guard_factory.ts b/factories/guards/session/guard_factory.ts deleted file mode 100644 index cdca68a..0000000 --- a/factories/guards/session/guard_factory.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { HttpContext } from '@adonisjs/core/http' - -import { SessionGuard } from '../../../src/guards/session/guard.js' -import type { - SessionGuardConfig, - SessionGuardEvents, - SessionUserProviderContract, -} from '../../../src/guards/session/types.js' -import { - FactoryUser, - TestLucidUserProvider, - LucidUserProviderFactory, -} from '../../core/lucid_user_provider.js' -import { EmitterLike } from '@adonisjs/core/types/events' -import { PROVIDER_REAL_USER } from '../../../src/auth/symbols.js' - -/** - * Exposes the API to create a session guard for testing. Under - * the hood configures Lucid models for looking up users - */ -export class SessionGuardFactory { - #config: SessionGuardConfig = { rememberMeTokenAge: '5y' } - - merge(config: SessionGuardConfig) { - this.#config = config - return this - } - - create< - UserProvider extends SessionUserProviderContract = TestLucidUserProvider< - typeof FactoryUser - >, - >( - ctx: HttpContext, - emitter: EmitterLike>, - provider?: UserProvider - ) { - return new SessionGuard( - 'web', - this.#config, - ctx, - emitter, - provider || new LucidUserProviderFactory().create() - ) - } -} diff --git a/factories/guards/session/main.ts b/factories/guards/session/main.ts deleted file mode 100644 index 86a4e16..0000000 --- a/factories/guards/session/main.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export { SessionGuardFactory } from './guard_factory.js' -export { DatabaseRememberTokenFactory } from './database_remember_token_factory.js' diff --git a/index.ts b/index.ts index 9c27bef..f70df41 100644 --- a/index.ts +++ b/index.ts @@ -7,9 +7,10 @@ * file that was distributed with this source code. */ +export * as errors from './src/errors.js' export { configure } from './configure.js' -export * as symbols from './src/auth/symbols.js' -export { AuthManager } from './src/auth/auth_manager.js' -export { Authenticator } from './src/auth/authenticator.js' -export { defineConfig, providers } from './src/auth/define_config.js' -export { AuthenticationException, InvalidCredentialsException } from './src/auth/errors.js' +export * as symbols from './src/symbols.js' +export { AuthManager } from './src/auth_manager.js' +export { defineConfig } from './src/define_config.js' +export { Authenticator } from './src/authenticator.js' +export { AuthenticatorClient } from './src/authenticator_client.js' diff --git a/package.json b/package.json index 723a62f..e2bc7f5 100644 --- a/package.json +++ b/package.json @@ -20,19 +20,12 @@ }, "exports": { ".": "./build/index.js", - "./types": "./build/src/auth/types.js", + "./types": "./build/src/types.js", "./auth_provider": "./build/providers/auth_provider.js", - "./plugins/api_client": "./build/src/auth/plugins/japa/api_client.js", - "./plugins/browser_client": "./build/src/auth/plugins/japa/browser_client.js", + "./plugins/api_client": "./build/src/plugins/japa/api_client.js", + "./plugins/browser_client": "./build/src/plugins/japa/browser_client.js", "./services/main": "./build/services/auth.js", - "./core/guard_user": "./build/src/core/guard_user.js", - "./core/user_providers/*": "./build/src/core/user_providers/*.js", - "./core/token_providers/*": "./build/src/core/token_providers/*.js", - "./types/core": "./build/src/core/types.js", - "./session": "./build/src/guards/session/main.js", - "./basic_auth": "./build/src/guards/basic_auth/main.js", - "./initialize_auth_middleware": "./build/src/auth/middleware/initialize_auth_middleware.js", - "./types/session": "./build/src/guards/session/types.js" + "./initialize_auth_middleware": "./build/src/middleware/initialize_auth_middleware.js" }, "scripts": { "pretest": "npm run lint", @@ -134,6 +127,7 @@ ], "exclude": [ "tests/**", + "backup/**", "factories/**" ] }, diff --git a/providers/auth_provider.ts b/providers/auth_provider.ts index fe6df01..4f3e13c 100644 --- a/providers/auth_provider.ts +++ b/providers/auth_provider.ts @@ -11,8 +11,8 @@ import { configProvider } from '@adonisjs/core' import { RuntimeException } from '@poppinss/utils' import type { ApplicationService } from '@adonisjs/core/types' -import type { AuthService } from '../src/auth/types.js' -import { AuthManager } from '../src/auth/auth_manager.js' +import type { AuthService } from '../src/types.js' +import { AuthManager } from '../src/auth_manager.js' declare module '@adonisjs/core/types' { export interface ContainerBindings { diff --git a/services/auth.ts b/services/auth.ts index 0bb8e11..807b9ff 100644 --- a/services/auth.ts +++ b/services/auth.ts @@ -8,7 +8,7 @@ */ import app from '@adonisjs/core/services/app' -import { AuthService } from '../src/auth/types.js' +import { AuthService } from '../src/types.js' let auth: AuthService diff --git a/src/auth/errors.ts b/src/auth/errors.ts deleted file mode 100644 index 2afe2b6..0000000 --- a/src/auth/errors.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { I18n } from '@adonisjs/i18n' -import { Exception } from '@poppinss/utils' -import { HttpContext } from '@adonisjs/core/http' - -/** - * Authentication exception is raised when an attempt is - * made to authenticate an HTTP request - */ -export class AuthenticationException extends Exception { - static status?: number | undefined = 401 - static code?: string | undefined = 'E_UNAUTHORIZED_ACCESS' - - /** - * Raises authentication exception when session guard - * is unable to authenticate the request - */ - static E_INVALID_AUTH_SESSION() { - return new AuthenticationException('Invalid or expired authentication session', { - code: 'E_INVALID_AUTH_SESSION', - status: 401, - guardDriverName: 'session', - }) - } - - /** - * Raises authentication exception when session guard - * is unable to authenticate the request - */ - static E_INVALID_BASIC_AUTH_CREDENTIALS() { - return new AuthenticationException('Invalid basic auth credentials', { - code: 'E_INVALID_BASIC_AUTH_CREDENTIALS', - status: 401, - guardDriverName: 'basic_auth', - }) - } - - guardDriverName: string - redirectTo?: string - identifier = 'auth.authenticate' - - constructor( - message: string, - options: ErrorOptions & { - guardDriverName: string - redirectTo?: string - code?: string - status?: number - } - ) { - super(message, options) - this.guardDriverName = options.guardDriverName - this.redirectTo = options.redirectTo - } - - /** - * Returns the message to be sent in the HTTP response. - * Feel free to override this method and return a custom - * response. - */ - getResponseMessage(error: AuthenticationException, ctx: HttpContext) { - if ('i18n' in ctx) { - return (ctx.i18n as I18n).t(error.identifier, {}, error.message) - } - return error.message - } - - /** - * A collection of authentication exception - * renderers to render the exception to a - * response. - * - * The collection is a key-value pair, where the - * key is the guard driver name and value is - * a factory function to respond to the - * request. - */ - renderers: Record< - string, - (message: string, error: AuthenticationException, ctx: HttpContext) => Promise | void - > = { - session: (message, error, ctx) => { - switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { - case 'html': - case null: - ctx.session.flashExcept(['_csrf']) - ctx.session.flashErrors({ [error.identifier]: [message] }) - ctx.response.redirect(error.redirectTo || '/', true) - break - case 'json': - ctx.response.status(error.status).send({ - errors: [ - { - message, - }, - ], - }) - break - case 'application/vnd.api+json': - ctx.response.status(error.status).send({ - errors: [ - { - code: error.identifier, - title: message, - }, - ], - }) - break - } - }, - basic_auth: (message, _, ctx) => { - ctx.response - .status(this.status) - .header('WWW-Authenticate', `Basic realm="Authenticate", charset="UTF-8"`) - .send(message) - }, - } - - /** - * Self handles the auth exception and converts it to an - * HTTP response - */ - async handle(error: AuthenticationException, ctx: HttpContext) { - const renderer = this.renderers[this.guardDriverName] - const message = error.getResponseMessage(error, ctx) - - if (!renderer) { - return ctx.response.status(error.status).send(message) - } - - return renderer(message, error, ctx) - } -} - -/** - * Invalid credentials exception is raised when unable - * to verify user credentials during login - */ -export class InvalidCredentialsException extends Exception { - static message: string = 'Invalid credentials' - static code: string = 'E_INVALID_CREDENTIALS' - static status?: number | undefined = 400 - - static E_INVALID_CREDENTIALS(guardDriverName: string) { - return new InvalidCredentialsException(InvalidCredentialsException.message, { - guardDriverName, - }) - } - - guardDriverName: string - identifier = 'auth.login' - - constructor( - message: string, - options: ErrorOptions & { - guardDriverName: string - code?: string - status?: number - } - ) { - super(message, options) - this.guardDriverName = options.guardDriverName - } - - /** - * Returns the message to be sent in the HTTP response. - * Feel free to override this method and return a custom - * response. - */ - getResponseMessage(error: InvalidCredentialsException, ctx: HttpContext) { - if ('i18n' in ctx) { - return (ctx.i18n as I18n).t(this.identifier, {}, error.message) - } - return error.message - } - - /** - * A collection of authentication exception - * renderers to render the exception to a - * response. - * - * The collection is a key-value pair, where the - * key is the guard driver name and value is - * a factory function to respond to the - * request. - */ - renderers: Record< - string, - (message: string, error: InvalidCredentialsException, ctx: HttpContext) => Promise | void - > = { - session: (message, error, ctx) => { - switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { - case 'html': - case null: - ctx.session.flashExcept(['_csrf']) - ctx.session.flashErrors({ [this.identifier]: [message] }) - ctx.response.redirect().withQs().back() - break - case 'json': - ctx.response.status(error.status).send({ - errors: [ - { - message: message, - }, - ], - }) - break - case 'application/vnd.api+json': - ctx.response.status(error.status).send({ - errors: [ - { - code: this.identifier, - title: message, - }, - ], - }) - break - } - }, - } - - /** - * Self handles the auth exception and converts it to an - * HTTP response - */ - async handle(error: InvalidCredentialsException, ctx: HttpContext) { - const renderer = this.renderers[this.guardDriverName] - const message = this.getResponseMessage(error, ctx) - - if (!renderer) { - return ctx.response.status(error.status).send(message) - } - - return renderer(message, error, ctx) - } -} diff --git a/src/auth/user_providers/main.ts b/src/auth/user_providers/main.ts deleted file mode 100644 index 82c2d22..0000000 --- a/src/auth/user_providers/main.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { BaseLucidUserProvider } from '../../core/user_providers/lucid.js' -import type { LucidAuthenticatable, UserProviderContract } from '../../core/types.js' - -/** - * Using lucid models to find users for session - * auth - */ -export class LucidUserProvider - extends BaseLucidUserProvider - implements UserProviderContract> {} diff --git a/src/auth/auth_manager.ts b/src/auth_manager.ts similarity index 81% rename from src/auth/auth_manager.ts rename to src/auth_manager.ts index 7b26d83..75c7d50 100644 --- a/src/auth/auth_manager.ts +++ b/src/auth_manager.ts @@ -38,14 +38,16 @@ export class AuthManager> { } /** - * Create an authenticator for a given HTTP request + * Create an authenticator for a given HTTP request. The authenticator + * is used to authenticated in incoming HTTP request */ createAuthenticator(ctx: HttpContext) { return new Authenticator(ctx, this.#config) } /** - * Creates an instance of the authenticator client + * Creates an instance of the authenticator client. The client is + * used to setup authentication state during testing. */ createAuthenticatorClient() { return new AuthenticatorClient(this.#config) diff --git a/src/auth/authenticator.ts b/src/authenticator.ts similarity index 64% rename from src/auth/authenticator.ts rename to src/authenticator.ts index 196b643..585944f 100644 --- a/src/auth/authenticator.ts +++ b/src/authenticator.ts @@ -8,45 +8,54 @@ */ import type { HttpContext } from '@adonisjs/core/http' +import { RuntimeException } from '@adonisjs/core/exceptions' import debug from './debug.js' import type { GuardFactory } from './types.js' -import { AuthenticationException } from './errors.js' +import { E_UNAUTHORIZED_ACCESS } from './errors.js' /** - * Authenticator is an HTTP request specific implementation for using - * guards to login users and authenticate requests. + * Authenticator is used to authenticate incoming HTTP requests + * using one or more known guards. */ export class Authenticator> { /** - * Name of the guard using which the authentication was last - * attempted. + * Registered guards */ - #authenticationAttemptedViaGuard?: keyof KnownGuards + #config: { + default: keyof KnownGuards + guards: KnownGuards + } /** - * Name of the guard using which the request has - * been authenticated + * Cache of guards created during the HTTP request */ - #authenticatedViaGuard?: keyof KnownGuards + #guardsCache: Partial> = {} /** - * Reference to HTTP context + * Last guard that was used to perform the authentication via + * the "authenticateUsing" method. + * + * @note + * Reset on every call made to "authenticate", "check" and + * "authenticateUsing" method. */ - #ctx: HttpContext + #authenticationAttemptedViaGuard?: keyof KnownGuards /** - * Registered guards + * Name of the guard using which the request has + * been authenticated successfully. + * + * @note + * Reset on every call made to "authenticate", "check" and + * "authenticateUsing" method. */ - #config: { - default: keyof KnownGuards - guards: KnownGuards - } + #authenticatedViaGuard?: keyof KnownGuards /** - * Cache of guards created during the HTTP request + * Reference to HTTP context */ - #guardsCache: Partial> = {} + #ctx: HttpContext /** * Name of the default guard @@ -64,10 +73,9 @@ export class Authenticator> { } /** - * A boolean to know if the current request has - * been authenticated. The property returns false - * when "authenticate" or "authenticateUsing" methods - * are not used + * A boolean to know if the current request has been authenticated. The + * property returns false when "authenticate" or "authenticateUsing" + * methods are not used. */ get isAuthenticated(): boolean { if (!this.#authenticationAttemptedViaGuard) { @@ -78,9 +86,9 @@ export class Authenticator> { } /** - * Reference to the currently authenticated user. The property - * returns undefined when "authenticate" or "authenticateUsing" - * methods are not used. + * Reference to the currently authenticated user. The property returns + * undefined when "authenticate" or "authenticateUsing" methods are + * not used. */ get user(): { [K in keyof KnownGuards]: ReturnType['user'] @@ -94,9 +102,9 @@ export class Authenticator> { /** * Whether or not the authentication has been attempted during - * the current request. The property returns false - * when "authenticate" or "authenticateUsing" methods - * are not used + * the current request. The property returns false when the + * "authenticate" or "authenticateUsing" methods are not + * used. */ get authenticationAttempted(): boolean { if (!this.#authenticationAttemptedViaGuard) { @@ -120,7 +128,9 @@ export class Authenticator> { [K in keyof KnownGuards]: ReturnType['getUserOrFail']> }[keyof KnownGuards] { if (!this.#authenticatedViaGuard) { - throw AuthenticationException.E_INVALID_AUTH_SESSION() + throw new RuntimeException( + 'Cannot access authenticated user. Please call "auth.authenticate" method first.' + ) } return this.use(this.#authenticatedViaGuard).getUserOrFail() as { @@ -140,7 +150,7 @@ export class Authenticator> { */ const cachedGuard = this.#guardsCache[guardToUse] if (cachedGuard) { - debug('using guard from cache. name: "%s"', guardToUse) + debug('authenticator: using guard from cache. name: "%s"', guardToUse) return cachedGuard as ReturnType } @@ -149,7 +159,7 @@ export class Authenticator> { /** * Construct guard and cache it */ - debug('creating guard. name: "%s"', guardToUse) + debug('authenticator: creating guard. name: "%s"', guardToUse) const guardInstance = guardFactory(this.#ctx) this.#guardsCache[guardToUse] = guardInstance @@ -157,30 +167,50 @@ export class Authenticator> { } /** - * Authenticate current request using the default guard + * Authenticate current request using the default guard. Calling this + * method multiple times triggers multiple authentication with the + * guard. */ authenticate() { return this.authenticateUsing() } /** - * Authenticate the request using all of the mentioned - * guards or the default guard. + * Silently attempt to authenticate the request using the default + * guard. Calling this method multiple times triggers multiple + * authentication with the guard. + */ + async check() { + this.#authenticationAttemptedViaGuard = this.defaultGuard + const isAuthenticated = await this.use().check() + if (isAuthenticated) { + this.#authenticatedViaGuard = this.defaultGuard + } + + return isAuthenticated + } + + /** + * Authenticate the request using all of the mentioned guards + * or the default guard. * - * The authentication process will stop after any of the - * mentioned guards is able to authenticate the request - * successfully. + * The authentication process will stop after any of the mentioned + * guards is able to authenticate the request successfully. * - * Otherwise, "AuthenticationException" will be raised. + * Otherwise, "E_UNAUTHORIZED_ACCESS" will be raised. */ - async authenticateUsing(guards?: (keyof KnownGuards)[], options?: { loginRoute?: string }) { + async authenticateUsing( + guards?: (keyof KnownGuards)[], + options?: { loginRoute?: string } + ): Promise { const guardsToUse = guards || [this.defaultGuard] let lastUsedDriver: string | undefined for (let guardName of guardsToUse) { debug('attempting to authenticate using guard "%s"', guardName) - const guard = this.use(guardName) + this.#authenticationAttemptedViaGuard = guardName + const guard = this.use(guardName) lastUsedDriver = guard.driverName if (await guard.check()) { @@ -189,8 +219,7 @@ export class Authenticator> { } } - throw new AuthenticationException('Unauthorized access', { - code: 'E_UNAUTHORIZED_ACCESS', + throw new E_UNAUTHORIZED_ACCESS('Unauthorized access', { guardDriverName: lastUsedDriver!, redirectTo: options?.loginRoute, }) diff --git a/src/auth/authenticator_client.ts b/src/authenticator_client.ts similarity index 81% rename from src/auth/authenticator_client.ts rename to src/authenticator_client.ts index e4523cc..a2310ce 100644 --- a/src/auth/authenticator_client.ts +++ b/src/authenticator_client.ts @@ -12,10 +12,10 @@ import type { GuardFactory } from './types.js' import { HttpContextFactory } from '@adonisjs/core/factories/http' /** - * Authenticator client is used to create guard instances for - * testing. It passes a fake HTTPContext to the guards, so - * make sure to not call server side APIs that might be - * relying on a real HTTPContext instance + * Authenticator client is used to create guard instances for testing. + * It passes a fake HTTPContext to the guards, so make sure to not + * call server side APIs that might be relying on a real + * HTTPContext instance. */ export class AuthenticatorClient> { /** @@ -55,7 +55,7 @@ export class AuthenticatorClient } @@ -64,7 +64,7 @@ export class AuthenticatorClient { - protected realUser: RealUser - constructor(realUser: RealUser) { - this.realUser = realUser - } - - /** - * Returns a value to uniquely identify the user. - */ - abstract getId(): number | string - - /** - * Returns the original provider specific user object. - */ - getOriginal(): RealUser { - return this.realUser - } -} diff --git a/src/core/token_providers/database.ts b/src/core/token_providers/database.ts deleted file mode 100644 index a96117d..0000000 --- a/src/core/token_providers/database.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { Database } from '@adonisjs/lucid/database' - -import debug from '../../auth/debug.js' -import type { DatabaseTokenProviderOptions, TokenProviderContract } from '../types.js' - -/** - * A generic implementation to read tokens from the database - */ -export abstract class DatabaseTokenProvider, Token> - implements TokenProviderContract -{ - constructor( - /** - * Reference to the database query builder needed to - * query the database for tokens - */ - protected db: Database, - - /** - * Options accepted - */ - protected options: DatabaseTokenProviderOptions - ) { - debug('db_token_provider: options %O', options) - } - - /** - * Should parse token to a database token row - */ - protected abstract parseToken(token: Token): DatabaseTokenRow - - /** - * Abstract method to prepare a token from the database - * row - */ - protected abstract prepareToken(dbRow: DatabaseTokenRow): Token | null - - /** - * Returns an instance of the query builder - */ - protected getQueryBuilder() { - return this.db.connection(this.options.connection).query() - } - - /** - * Returns an instance of the query builder for insert - * queries - */ - protected getInsertQueryBuilder() { - return this.db.connection(this.options.connection).insertQuery() - } - - /** - * Persists token inside the database - */ - async createToken(token: Token): Promise { - const parsedToken = this.parseToken(token) - debug('db_token_provider: creating token %O', parsedToken) - - await this.getInsertQueryBuilder() - .table(this.options.table) - .insert({ - ...parsedToken, - }) - } - - /** - * Finds a token by series inside the database and returns an - * instance of it. - * - * Returns null if the token is missing or expired - */ - async getTokenBySeries(series: string): Promise { - debug('db_token_provider: reading token by series %s', series) - const token = await this.getQueryBuilder() - .from(this.options.table) - .where('series', series) - .limit(1) - .first() - - if (!token) { - debug('db_token_provider: cannot find token for series %s', series) - return null - } - - debug('db_token_provider: token found %O', token) - return this.prepareToken(token) - } - - /** - * Removes a token from the database by the - * series number - */ - async deleteTokenBySeries(series: string): Promise { - debug('db_token_provider: deleting token by series %s', series) - await this.getQueryBuilder().from(this.options.table).where('series', series).del() - } - - /** - * Updates token hash and expiry - */ - async updateTokenBySeries(series: string, token: Token): Promise { - const parsedToken = this.parseToken(token) - - debug('db_token_provider: updating token by series %s: %O', series, parsedToken) - - await this.getQueryBuilder() - .from(this.options.table) - .where('series', series) - .update({ ...parsedToken }) - } -} diff --git a/src/core/types.ts b/src/core/types.ts deleted file mode 100644 index 8b672e2..0000000 --- a/src/core/types.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { HashersList } from '@adonisjs/core/types' -import type { QueryClientContract } from '@adonisjs/lucid/types/database' - -import type { GuardUser } from './guard_user.js' -import type { PROVIDER_REAL_USER } from '../auth/symbols.js' -import type { LucidModel } from '@adonisjs/lucid/types/model' - -/** - * The UserProvider is used to lookup a user for authentication - */ -export interface UserProviderContract { - [PROVIDER_REAL_USER]: RealUser - - /** - * Creates a user object that guards can use for - * authentication. - */ - createUserForGuard(user: RealUser): Promise> - - /** - * Find a user by uid. The uid could be one or multiple fields - * to unique identify a user. - * - * This method is called when finding a user for login - */ - findByUid(value: string | number): Promise | null> - - /** - * Find a user by unique primary id. This method is called when - * authenticating user from their session. - */ - findById(value: string | number): Promise | null> - - /** - * Find a user by uid and verify their password. This method prevents - * timing attacks. - */ - verifyCredentials(uid: string | number, password: string): Promise | null> -} - -/** - * The TokenProvider is used to lookup/persist tokens during authentication - */ -export interface TokenProviderContract { - /** - * Returns a token by the series counter, or null when token is - * missing - */ - getTokenBySeries(series: string): Promise - - /** - * Deletes a token by the series counter - */ - deleteTokenBySeries(series: string): Promise - - /** - * Updates a token by the series counter - */ - updateTokenBySeries(series: string, token: Token): Promise - - /** - * Creates a new token and persists it to the database - */ - createToken(token: Token): Promise -} - -/** - * A lucid model that can be used during authentication - */ -export type LucidAuthenticatable = LucidModel & { - /** - * Optional static method to customize the user lookup - * during "findByUid" method call. - */ - getUserForAuth?(uids: string[], value: string | number): Promise -} - -/** - * Options accepted by the Lucid user provider - */ -export type LucidUserProviderOptions = { - /** - * Define the hasher to use to hash and verify - * passwords - */ - hasher?: keyof HashersList - - /** - * Optionally define the connection to use when making database - * queries - */ - connection?: string - - /** - * Optionally define the query client instance to use for making - * database queries. - * - * When both "connection" and "client" are defined, the client will - * be given the preference. - */ - client?: QueryClientContract - - /** - * Model to use for authentication - */ - model: () => Promise<{ default: Model }> - - /** - * Column name to read the hashed password - */ - passwordColumnName: Extract, string> - - /** - * An array of uids to use when finding a user for login. Make - * sure all fields can be used to uniquely lookup a user. - */ - uids: Extract, string>[] -} - -/** - * Options accepted by the Database token provider - */ -export type DatabaseTokenProviderOptions = { - /** - * Optionally define the connection to use when making database - * queries - */ - connection?: string - - /** - * Database table to query to find the user - */ - table: string -} diff --git a/src/core/user_providers/lucid.ts b/src/core/user_providers/lucid.ts deleted file mode 100644 index 416712e..0000000 --- a/src/core/user_providers/lucid.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { Hash } from '@adonisjs/core/hash' -import { RuntimeException } from '@poppinss/utils' - -import debug from '../../auth/debug.js' -import { GuardUser } from '../guard_user.js' -import { PROVIDER_REAL_USER } from '../../auth/symbols.js' -import type { - UserProviderContract, - LucidAuthenticatable, - LucidUserProviderOptions, -} from '../types.js' - -/** - * Lucid user represents a guard user, used by authentication guards - * to perform authentication. - */ -class LucidUser> extends GuardUser { - /** - * @inheritdoc - */ - getId(): string | number { - const id = this.realUser.$primaryKeyValue - - /** - * Ensure id exists - */ - if (!id) { - const model = this.realUser.constructor as LucidAuthenticatable - const modelName = model.name - const primaryKey = model.primaryKey - throw new RuntimeException( - `Cannot use "${modelName}" model for authentication. The value of column "${primaryKey}" is undefined or null` - ) - } - - return id - } -} - -/** - * Lucid user provider is used to lookup user for authentication - * using a Lucid model. - */ -export abstract class BaseLucidUserProvider - implements UserProviderContract> -{ - declare [PROVIDER_REAL_USER]: InstanceType - - /** - * Reference to the lazily imported model - */ - protected model?: UserModel - - constructor( - /** - * Hasher is used to verify plain text passwords - */ - protected hasher: Hash, - - /** - * Lucid provider options - */ - protected options: LucidUserProviderOptions - ) { - debug('lucid_user_provider: options %O', options) - } - - /** - * Imports the model from the provider, returns and caches it - * for further operations. - */ - protected async getModel() { - if (this.model) { - return this.model - } - - const importedModel = await this.options.model() - this.model = importedModel.default - debug('lucid_user_provider: using model [class %s]', this.model.name) - return this.model - } - - /** - * Returns an instance of the query builder - */ - protected getQueryBuilder(model: UserModel) { - return model.query({ - client: this.options.client, - connection: this.options.connection, - }) - } - - /** - * Returns an instance of the "LucidUser" that guards - * can use for authentication - */ - async createUserForGuard(user: InstanceType) { - const model = await this.getModel() - if (user instanceof model === false) { - throw new RuntimeException( - `Invalid user object. It must be an instance of the "${model.name}" model` - ) - } - - debug('lucid_user_provider: converting user object to guard user %O', user) - return new LucidUser(user) - } - - /** - * Finds a user by id using the configured model. - */ - async findById(value: string | number): Promise> | null> { - debug('lucid_user_provider: finding user by id %s', value) - - const model = await this.getModel() - const user = await model.find(value, { - client: this.options.client, - connection: this.options.connection, - }) - - if (!user) { - return null - } - - return new LucidUser(user) - } - - /** - * Finds a user using one of the pre-configured unique - * ids, via the configured model. - */ - async findByUid(value: string | number): Promise> | null> { - const model = await this.getModel() - - /** - * Use custom lookup method when defined on the - * model. - */ - if ('getUserForAuth' in model && typeof model.getUserForAuth === 'function') { - debug('lucid_user_provider: using getUserForAuth method on "[class %s]"', model.name) - - const user = await model.getUserForAuth(this.options.uids, value) - if (!user) { - return null - } - - return new LucidUser(user) - } - - /** - * Self query - */ - const query = this.getQueryBuilder(model) - this.options.uids.forEach((uid) => query.orWhere(uid, value)) - - debug( - 'lucid_user_provider: finding user by uids, uids: %O, value: %s', - this.options.uids, - value - ) - - const user = await query.limit(1).first() - if (!user) { - return null - } - - return new LucidUser(user) - } - - /** - * Find a user by uid and verify their password. This method prevents - * timing attacks. - */ - async verifyCredentials( - uid: string | number, - password: string - ): Promise> | null> { - const user = await this.findByUid(uid) - if (user) { - const passwordHash = user.getOriginal()[this.options.passwordColumnName] - if (!passwordHash) { - throw new RuntimeException( - `Cannot verify password during login. The value of column "${this.options.passwordColumnName}" is undefined or null` - ) - } - - if (await this.hasher.verify(passwordHash as string, password)) { - return user - } - return null - } - - /** - * Hashing the password to prevent timing attacks. - */ - await this.hasher.make(password) - return null - } -} diff --git a/src/auth/debug.ts b/src/debug.ts similarity index 100% rename from src/auth/debug.ts rename to src/debug.ts diff --git a/src/auth/define_config.ts b/src/define_config.ts similarity index 69% rename from src/auth/define_config.ts rename to src/define_config.ts index c7a491f..95fbd4a 100644 --- a/src/auth/define_config.ts +++ b/src/define_config.ts @@ -11,10 +11,7 @@ import { configProvider } from '@adonisjs/core' import type { ConfigProvider } from '@adonisjs/core/types' - import type { GuardConfigProvider, GuardFactory } from './types.js' -import type { LucidUserProvider } from './user_providers/main.js' -import type { LucidAuthenticatable, LucidUserProviderOptions } from '../core/types.js' /** * Config resolved by the "defineConfig" method @@ -60,21 +57,3 @@ export function defineConfig< } as ResolvedAuthConfig }) } - -/** - * Providers helper to configure user providers for - * finding users for authentication - */ -export const providers: { - lucid: ( - config: LucidUserProviderOptions - ) => ConfigProvider> -} = { - lucid(config) { - return configProvider.create(async (app) => { - const { LucidUserProvider } = await import('./user_providers/main.js') - const hasher = await app.container.make('hash') - return new LucidUserProvider(config.hasher ? hasher.use(config.hasher) : hasher.use(), config) - }) - }, -} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..a4531e4 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,135 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { I18n } from '@adonisjs/i18n' +import { Exception } from '@poppinss/utils' +import { HttpContext } from '@adonisjs/core/http' + +/** + * The "E_UNAUTHORIZED_ACCESS" exception is raised when unable to + * authenticate an incoming HTTP request. + * + * The "error.guardDriverName" can be used to know the driver which + * raised the error. + */ +export const E_UNAUTHORIZED_ACCESS = class extends Exception { + static status: number = 401 + static code: string = 'E_UNAUTHORIZED_ACCESS' + + /** + * Endpoint to redirect to. Only used by "session" driver + * renderer + */ + redirectTo?: string + + /** + * Translation identifier. Can be customized + */ + identifier: string = 'errors.E_UNAUTHORIZED_ACCESS' + + /** + * The guard name reference that raised the exception. It allows + * us to customize the logic of handling the exception. + */ + guardDriverName: string + + /** + * A collection of renderers to render the exception to a + * response. + * + * The collection is a key-value pair, where the key is + * the guard driver name and value is a factory function + * to respond to the request. + */ + renderers: Record< + string, + (message: string, error: this, ctx: HttpContext) => Promise | void + > = { + /** + * Response when session driver is used + */ + session: (message, error, ctx) => { + switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { + case 'html': + case null: + ctx.session.flashExcept(['_csrf']) + ctx.session.flashErrors({ [error.code!]: message }) + ctx.response.redirect(error.redirectTo || '/', true) + break + case 'json': + ctx.response.status(error.status).send({ + errors: [ + { + message, + }, + ], + }) + break + case 'application/vnd.api+json': + ctx.response.status(error.status).send({ + errors: [ + { + code: error.code, + title: message, + }, + ], + }) + break + } + }, + + /** + * Response when basic auth driver is used + */ + basic_auth: (message, _, ctx) => { + ctx.response + .status(this.status) + .header('WWW-Authenticate', `Basic realm="Authenticate", charset="UTF-8"`) + .send(message) + }, + } + + /** + * Returns the message to be sent in the HTTP response. + * Feel free to override this method and return a custom + * response. + */ + getResponseMessage(error: this, ctx: HttpContext) { + if ('i18n' in ctx) { + return (ctx.i18n as I18n).t(error.identifier, {}, error.message) + } + return error.message + } + + constructor( + message: string, + options: { + redirectTo?: string + guardDriverName: string + } + ) { + super(message, {}) + this.guardDriverName = options.guardDriverName + this.redirectTo = options.redirectTo + } + + /** + * Converts exception to an HTTP response + */ + async handle(error: this, ctx: HttpContext) { + const renderer = this.renderers[this.guardDriverName] + const message = error.getResponseMessage(error, ctx) + + if (!renderer) { + return ctx.response.status(error.status).send(message) + } + + return renderer(message, error, ctx) + } +} diff --git a/src/guards/basic_auth/define_config.ts b/src/guards/basic_auth/define_config.ts deleted file mode 100644 index 397e762..0000000 --- a/src/guards/basic_auth/define_config.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { configProvider } from '@adonisjs/core' -import { RuntimeException } from '@poppinss/utils' -import type { HttpContext } from '@adonisjs/core/http' -import type { ConfigProvider } from '@adonisjs/core/types' - -import type { GuardConfigProvider } from '../../auth/types.js' -import type { UserProviderContract } from '../../core/types.js' - -import { BasicAuthGuard } from './guard.js' - -/** - * Helper function to configure the basic auth guard for - * authentication. - * - * This method returns a config builder, which internally - * returns a factory function to construct a guard - * during HTTP requests. - */ -export function basicAuthGuard>(config: { - provider: ConfigProvider -}): GuardConfigProvider<(ctx: HttpContext) => BasicAuthGuard> { - return { - async resolver(guardName, app) { - const provider = await configProvider.resolve(app, config.provider) - if (!provider) { - throw new RuntimeException(`Invalid user provider defined on "${guardName}" guard`) - } - - const emitter = await app.container.make('emitter') - - /** - * Factory function needed by Authenticator to switch - * between guards and perform authentication - */ - return (ctx) => { - return new BasicAuthGuard(guardName, ctx, emitter as any, provider) - } - }, - } -} diff --git a/src/guards/basic_auth/guard.ts b/src/guards/basic_auth/guard.ts deleted file mode 100644 index d53572d..0000000 --- a/src/guards/basic_auth/guard.ts +++ /dev/null @@ -1,212 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import auth from 'basic-auth' -import type { HttpContext } from '@adonisjs/core/http' -import { Exception, RuntimeException } from '@poppinss/utils' -import type { EmitterLike } from '@adonisjs/core/types/events' - -import debug from '../../auth/debug.js' -import type { BasicAuthGuardEvents } from './types.js' -import type { GuardContract } from '../../auth/types.js' -import type { UserProviderContract } from '../../core/types.js' -import { AuthenticationException } from '../../auth/errors.js' -import { PROVIDER_REAL_USER, GUARD_KNOWN_EVENTS } from '../../auth/symbols.js' - -/** - * Implementation of basic auth as an authentication guard - */ -export class BasicAuthGuard> - implements GuardContract -{ - declare [GUARD_KNOWN_EVENTS]: BasicAuthGuardEvents - - /** - * A unique name for the guard. It is used while - * emitting events - */ - #name: string - - /** - * Reference to the current HTTP context - */ - #ctx: HttpContext - - /** - * Provider to lookup user details - */ - #userProvider: UserProvider - - /** - * Emitter to emit events - */ - #emitter: EmitterLike> - - /** - * Driver name of the guard - */ - driverName: 'basic_auth' = 'basic_auth' - - /** - * Whether or not the authentication has been attempted - * during the current request - */ - authenticationAttempted = false - - /** - * A boolean to know if the current request has - * been authenticated - */ - isAuthenticated = false - - /** - * Reference to an instance of the authenticated or logged-in - * user. The value only exists after calling one of the - * following methods. - * - * - authenticate - * - * You can use the "getUserOrFail" method to throw an exception if - * the request is not authenticated. - */ - user?: UserProvider[typeof PROVIDER_REAL_USER] - - constructor( - name: string, - ctx: HttpContext, - emitter: EmitterLike>, - userProvider: UserProvider - ) { - this.#ctx = ctx - this.#name = name - this.#emitter = emitter - this.#userProvider = userProvider - } - - /** - * Notifies about authentication failure and throws the exception - */ - #authenticationFailed(error: Exception): never { - this.#emitter.emit('basic_auth:authentication_failed', { - ctx: this.#ctx, - guardName: this.#name, - error, - }) - throw error - } - - /** - * Returns an instance of the authenticated user. Or throws - * an exception if the request is not authenticated. - */ - getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { - if (!this.user) { - throw AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS() - } - return this.user - } - - /** - * Verifies user credentials and returns an instance of - * the user or throws "E_INVALID_BASIC_AUTH_CREDENTIALS" exception. - */ - async verifyCredentials( - uid: string, - password: string - ): Promise { - debug('basic_auth_guard: attempting to verify credentials for uid "%s"', uid) - - /** - * Attempt to verify credentials and raise error if they are invalid - */ - const providerUser = await this.#userProvider.verifyCredentials(uid, password) - if (!providerUser) { - this.#authenticationFailed(AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS()) - } - - return providerUser.getOriginal() - } - - /** - * Authenticates the current HTTP request for basic - * auth credentials - */ - async authenticate(): Promise { - /** - * Avoid re-authenticating when already authenticated - */ - if (this.authenticationAttempted) { - return this.getUserOrFail() - } - - /** - * Beginning authentication attempt - */ - this.authenticationAttempted = true - this.#emitter.emit('basic_auth:authentication_attempted', { - ctx: this.#ctx, - guardName: this.#name, - }) - - /** - * Fetch credentials from the header - */ - const credentials = auth(this.#ctx.request.request) - if (!credentials) { - this.#authenticationFailed(AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS()) - } - - debug('basic_auth_guard: authenticating user using credentials') - - /** - * Verifying user credentials - */ - this.user = await this.verifyCredentials(credentials.name, credentials.pass) - this.isAuthenticated = true - - debug('basic_auth_guard: marking user as authenticated') - - this.#emitter.emit('basic_auth:authentication_succeeded', { - ctx: this.#ctx, - guardName: this.#name, - user: this.user, - }) - - /** - * Return user - */ - return this.getUserOrFail() - } - - /** - * Silently attempt to authenticate the user. - * - * The method returns a boolean indicating if the authentication - * succeeded or failed. - */ - async check(): Promise { - try { - await this.authenticate() - return true - } catch (error) { - if (error instanceof AuthenticationException) { - return false - } - - throw error - } - } - - /** - * Not support - */ - async authenticateAsClient(_: UserProvider[typeof PROVIDER_REAL_USER]): Promise { - throw new RuntimeException('Cannot authenticate as a client when using basic auth') - } -} diff --git a/src/guards/basic_auth/main.ts b/src/guards/basic_auth/main.ts deleted file mode 100644 index f0f7b4b..0000000 --- a/src/guards/basic_auth/main.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export { BasicAuthGuard } from './guard.js' -export { basicAuthGuard } from './define_config.js' diff --git a/src/guards/basic_auth/types.ts b/src/guards/basic_auth/types.ts deleted file mode 100644 index df4ca21..0000000 --- a/src/guards/basic_auth/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Exception } from '@poppinss/utils' -import type { HttpContext } from '@adonisjs/core/http' - -/** - * Events emitted by the basic auth guard - */ -export type BasicAuthGuardEvents = { - /** - * The event is emitted when the user credentials - * have been verified successfully. - */ - 'basic_auth:credentials_verified': { - ctx: HttpContext - guardName: string - uid: string - user: User - } - - /** - * Attempting to authenticate the user - */ - 'basic_auth:authentication_attempted': { - ctx: HttpContext - guardName: string - } - - /** - * Authentication was successful - */ - 'basic_auth:authentication_succeeded': { - ctx: HttpContext - guardName: string - user: User - } - - /** - * Authentication failed - */ - 'basic_auth:authentication_failed': { - ctx: HttpContext - guardName: string - error: Exception - } -} diff --git a/src/guards/session/define_config.ts b/src/guards/session/define_config.ts deleted file mode 100644 index 384d509..0000000 --- a/src/guards/session/define_config.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { configProvider } from '@adonisjs/core' -import { RuntimeException } from '@poppinss/utils' -import type { HttpContext } from '@adonisjs/core/http' -import type { ConfigProvider } from '@adonisjs/core/types' - -import { SessionGuard } from './guard.js' -import type { GuardConfigProvider } from '../../auth/types.js' -import type { - SessionGuardConfig, - RememberMeProviderContract, - SessionUserProviderContract, - DatabaseRememberMeProviderOptions, -} from './types.js' - -/** - * Helper function to configure the session guard for - * authentication. - * - * This method returns a config builder, which internally - * returns a factory function to construct a guard - * during HTTP requests. - */ -export function sessionGuard>( - config: SessionGuardConfig & { - provider: ConfigProvider - tokens?: ConfigProvider - } -): GuardConfigProvider<(ctx: HttpContext) => SessionGuard> { - return { - async resolver(guardName, app) { - const provider = await configProvider.resolve(app, config.provider) - if (!provider) { - throw new RuntimeException(`Invalid user provider defined on "${guardName}" guard`) - } - - const emitter = await app.container.make('emitter') - const tokensProvider = config.tokens - ? await configProvider.resolve(app, config.tokens) - : undefined - - /** - * Factory function needed by Authenticator to switch - * between guards and perform authentication - */ - return (ctx) => { - const guard = new SessionGuard( - guardName, - config, - ctx, - emitter as any, - provider - ) - if (tokensProvider) { - guard.withRememberMeTokens(tokensProvider) - } - - return guard - } - }, - } -} - -/** - * Tokens provider helper to store remember me tokens - */ -export const tokensProvider: { - db: (config: DatabaseRememberMeProviderOptions) => ConfigProvider -} = { - db(config) { - return configProvider.create(async (app) => { - const db = await app.container.make('lucid.db') - const { DatabaseRememberTokenProvider } = await import('./token_providers/database.js') - return new DatabaseRememberTokenProvider(db, config) - }) - }, -} diff --git a/src/guards/session/guard.ts b/src/guards/session/guard.ts deleted file mode 100644 index 78b10ab..0000000 --- a/src/guards/session/guard.ts +++ /dev/null @@ -1,626 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import type { HttpContext } from '@adonisjs/core/http' -import { Exception, RuntimeException } from '@poppinss/utils' -import type { EmitterLike } from '@adonisjs/core/types/events' - -import debug from '../../auth/debug.js' -import { RememberMeToken } from './remember_me_token.js' -import type { GuardContract } from '../../auth/types.js' -import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../auth/symbols.js' -import { AuthenticationException, InvalidCredentialsException } from '../../auth/errors.js' -import type { - SessionGuardEvents, - SessionGuardConfig, - RememberMeProviderContract, - SessionUserProviderContract, -} from './types.js' - -/** - * Session guard uses sessions and cookies to login and authenticate - * users. - */ -export class SessionGuard> - implements GuardContract -{ - declare [GUARD_KNOWN_EVENTS]: SessionGuardEvents - - /** - * A unique name for the guard. It is used for prefixing - * session data and remember me cookies - */ - #name: string - - /** - * Reference to the current HTTP context - */ - #ctx: HttpContext - - /** - * Configuration - */ - #config: SessionGuardConfig - - /** - * Provider to lookup user details - */ - #userProvider: UserProvider - - /** - * The remember me tokens provider to use to persist - * remember me tokens - */ - #rememberMeTokenProvider?: RememberMeProviderContract - - /** - * Emitter to emit events - */ - #emitter: EmitterLike> - - /** - * Driver name of the guard - */ - driverName: 'session' = 'session' - - /** - * Whether or not the authentication has been attempted - * during the current request - */ - authenticationAttempted = false - - /** - * Find if the user has been logged out during - * the current request - */ - isLoggedOut = false - - /** - * A boolean to know if the current request has - * been authenticated - */ - isAuthenticated = false - - /** - * A boolean to know if the current request is authenticated - * using the "rememember_me" token. - */ - viaRemember = false - - /** - * Reference to an instance of the authenticated or logged-in - * user. The value only exists after calling one of the - * following methods. - * - * - login - * - loginViaId - * - attempt - * - authenticate - * - * You can use the "getUserOrFail" method to throw an exception if - * the request is not authenticated. - */ - user?: UserProvider[typeof PROVIDER_REAL_USER] - - /** - * The key used to store the logged-in user id inside - * session - */ - get sessionKeyName() { - return `auth_${this.#name}` - } - - /** - * The key used to store the remember me token cookie - */ - get rememberMeKeyName() { - return `remember_${this.#name}` - } - - constructor( - name: string, - config: SessionGuardConfig, - ctx: HttpContext, - emitter: EmitterLike>, - userProvider: UserProvider - ) { - this.#name = name - this.#ctx = ctx - this.#config = config - this.#emitter = emitter - this.#userProvider = userProvider - } - - /** - * Returns an instance of the tokens provider, ensuring - * it has been configured - */ - #getTokenProvider() { - if (!this.#rememberMeTokenProvider) { - throw new RuntimeException( - 'Cannot use "rememberMe" feature. Please configure the tokens provider inside config/auth file' - ) - } - - return this.#rememberMeTokenProvider - } - - /** - * Returns the session instance for the given request, - * ensuring the property exists - */ - #getSession() { - if (!('session' in this.#ctx)) { - throw new RuntimeException( - 'Cannot login user. Make sure you have installed the "@adonisjs/session" package and configured its middleware' - ) - } - - return this.#ctx.session - } - - /** - * Notifies about authentication failure and throws the exception - */ - #authenticationFailed(error: Exception, sessionId: string): never { - this.#emitter.emit('session_auth:authentication_failed', { - ctx: this.#ctx, - guardName: this.#name, - error, - sessionId: sessionId, - }) - - throw error - } - - /** - * Notifies about login failure and throws the exception - */ - #loginFailed(error: Exception): never { - this.#emitter.emit('session_auth:login_failed', { - ctx: this.#ctx, - guardName: this.#name, - error, - }) - - throw error - } - - /** - * Register the remember me tokens provider to create - * remember me tokens during user login. - * - * Note: This method only registers the remember me tokens provider - * and does not enable them. You must pass "rememberMe = true" during - * the "login" method call. - */ - withRememberMeTokens(tokensProvider: RememberMeProviderContract): this { - this.#rememberMeTokenProvider = tokensProvider - return this - } - - /** - * Returns an instance of the authenticated user. Or throws - * an exception if the request is not authenticated. - */ - getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { - if (!this.user) { - throw AuthenticationException.E_INVALID_AUTH_SESSION() - } - - return this.user - } - - /** - * Verifies user credentials and returns an instance of - * the user or throws "E_INVALID_CREDENTIALS" exception. - */ - async verifyCredentials( - uid: string, - password: string - ): Promise { - debug('session_guard: attempting to verify credentials for uid "%s"', uid) - - /** - * Attempt to verify credentials and raise error if they - * are invalid - */ - const providerUser = await this.#userProvider.verifyCredentials(uid, password) - if (!providerUser) { - this.#loginFailed(InvalidCredentialsException.E_INVALID_CREDENTIALS(this.driverName)) - } - - const user = providerUser.getOriginal() - - /** - * Notify credentials have been verified - */ - this.#emitter.emit('session_auth:credentials_verified', { - ctx: this.#ctx, - guardName: this.#name, - uid, - user, - }) - - return user - } - - /** - * Attempt to login a user after verifying their - * credentials. - */ - async attempt( - uid: string, - password: string, - remember?: boolean - ): Promise { - const user = await this.verifyCredentials(uid, password) - return this.login(user, remember) - } - - /** - * Attempt to login a user using the user id. The - * user will be first fetched from the db before - * marking them as logged-in - */ - async loginViaId( - id: string | number, - remember?: boolean - ): Promise { - debug('session_guard: attempting to login user via id "%s"', id) - - const providerUser = await this.#userProvider.findById(id) - if (!providerUser) { - this.#loginFailed(InvalidCredentialsException.E_INVALID_CREDENTIALS(this.driverName)) - } - - return this.login(providerUser.getOriginal(), remember) - } - - /** - * Login a user using the user object. - */ - async login( - user: UserProvider[typeof PROVIDER_REAL_USER], - remember: boolean = false - ): Promise { - this.#emitter.emit('session_auth:login_attempted', { - ctx: this.#ctx, - user, - guardName: this.#name, - }) - - const providerUser = await this.#userProvider.createUserForGuard(user) - const session = this.#getSession() - const userId = providerUser.getId() - - /** - * Create session and recycle the session id - */ - debug('session_guard: marking user with id "%s" as logged-in', userId) - session.put(this.sessionKeyName, userId) - session.regenerate() - - /** - * Manage remember me cookie - */ - let token: RememberMeToken | undefined - if (remember) { - const tokenProvider = this.#getTokenProvider() - const rememberMeTokenAge = this.#config.rememberMeTokenAge || '2years' - - /** - * Create a token - */ - token = RememberMeToken.create(providerUser.getId(), rememberMeTokenAge, this.#name) - - /** - * Persist remember me token inside the database - */ - await tokenProvider.createToken(token) - - /** - * Drop token value inside the cookie - */ - debug('session_guard: creating remember me cookie') - this.#ctx.response.encryptedCookie(this.rememberMeKeyName, token.value!.release(), { - maxAge: rememberMeTokenAge, - httpOnly: true, - }) - } else { - this.#ctx.response.clearCookie(this.rememberMeKeyName) - } - - /** - * Toggle properties to mark user as logged-in - */ - this.user = user - this.isLoggedOut = false - - /** - * Notify the login is successful - */ - this.#emitter.emit('session_auth:login_succeeded', { - ctx: this.#ctx, - guardName: this.#name, - user, - sessionId: session.sessionId, - rememberMeToken: token, - }) - - return user - } - - /** - * Authenticates the HTTP request to ensure the - * user is logged-in - */ - async authenticate(): Promise { - if (this.authenticationAttempted) { - return this.getUserOrFail() - } - - this.authenticationAttempted = true - const session = this.#getSession() - - /** - * Notify we are starting authentication process - */ - this.#emitter.emit('session_auth:authentication_attempted', { - ctx: this.#ctx, - guardName: this.#name, - sessionId: session.sessionId, - }) - - /** - * Check if there is a user id inside the session store. - * If yes, fetch the user from the persistent storage - * and mark them as logged-in - */ - const loggedInUserId = session.get(this.sessionKeyName) - if (loggedInUserId) { - debug('session_guard: authenticating user from session') - const providerUser = await this.#userProvider.findById(loggedInUserId) - - /** - * Throw error when user is not found inside the persistent - * storage - */ - if (!providerUser) { - this.#authenticationFailed( - AuthenticationException.E_INVALID_AUTH_SESSION(), - session.sessionId - ) - } - - debug('session_guard: marking user with id "%s" as authenticated', providerUser.getId()) - this.user = providerUser.getOriginal() - this.isAuthenticated = true - this.isLoggedOut = false - this.viaRemember = false - - /** - * Authentication was successful - */ - this.#emitter.emit('session_auth:authentication_succeeded', { - ctx: this.#ctx, - guardName: this.#name, - sessionId: session.sessionId, - user: this.user, - }) - - return this.user - } - - /** - * Otherwise check for remember me cookie and attempt - * to login user via that. - * - * Also, if the remember me token provider is not registered, - * we will silently ignore the remember me cookie and - * throw invalid session exception - * - * This is because, sometimes an app might use the remember me - * tokens initially and then back out and stop using them. In - * that case, we should not fail authentication attempts, just - * ignore the remember me cookie. - */ - const rememberMeCookie = this.#ctx.request.encryptedCookie(this.rememberMeKeyName) - if (!rememberMeCookie || !this.#rememberMeTokenProvider) { - this.#authenticationFailed( - AuthenticationException.E_INVALID_AUTH_SESSION(), - session.sessionId - ) - } - - debug('session_guard: authenticating user from remember me cookie') - - /** - * Decode remember me cookie and check for its existence inside - * the database. Throw invalid session exception when token - * is missing or invalid - */ - const decodedToken = RememberMeToken.decode(rememberMeCookie) - if (!decodedToken) { - this.#authenticationFailed( - AuthenticationException.E_INVALID_AUTH_SESSION(), - session.sessionId - ) - } - - /** - * Fail if token is missing, hash mis-matches, or the token guard does not - * match the current guards name - */ - const token = await this.#rememberMeTokenProvider.getTokenBySeries(decodedToken.series) - if (!token || !token.verify(decodedToken.value) || token.guard !== this.#name) { - this.#authenticationFailed( - AuthenticationException.E_INVALID_AUTH_SESSION(), - session.sessionId - ) - } - - debug('session_guard: found valid remember me token') - - /** - * Find user for whom the token was created. Throw invalid - * session exception when the user is missing - */ - const providerUser = await this.#userProvider.findById(token.userId) - if (!providerUser) { - this.#authenticationFailed( - AuthenticationException.E_INVALID_AUTH_SESSION(), - session.sessionId - ) - } - - /** - * Finally, login the user from the remember me token - */ - const userId = providerUser.getId() - debug('session_guard: marking user with id "%s" as logged in from remember me cookie', userId) - session.put(this.sessionKeyName, userId) - session.regenerate() - - debug('session_guard: marking user with id "%s" as authenticated', userId) - this.user = providerUser.getOriginal() - this.isAuthenticated = true - this.isLoggedOut = false - this.viaRemember = true - - /** - * Authentication was successful via remember me token - */ - this.#emitter.emit('session_auth:authentication_succeeded', { - ctx: this.#ctx, - guardName: this.#name, - sessionId: session.sessionId, - user: this.user!, - rememberMeToken: token, - }) - - /** - * ---------------------------------------------------------------- - * User is logged in now. From here on we are refreshing the - * remember me token. - * ---------------------------------------------------------------- - * - * Here we refresh the token value inside the db when the - * current remember_me token is older than 1 minute. - * - * Otherwise, we re-use the same token. This is to avoid race-conditions - * when parallel requests uses the remember_me token to authenticate - * the user. - * - * Finally, we will update remember_me cookie lifespan in both the cases. - * Be it updated the token inside databse, or not. - */ - const currentTime = new Date() - const rememberMeTokenAge = this.#config.rememberMeTokenAge || '2years' - const updatedAtWithBuffer = new Date(token.updatedAt) - updatedAtWithBuffer.setSeconds(updatedAtWithBuffer.getSeconds() + 60) - - if (updatedAtWithBuffer < currentTime) { - /** - * Refresh and update the token - */ - token.refresh(rememberMeTokenAge) - await this.#rememberMeTokenProvider.updateTokenBySeries(token.series, token) - - this.#ctx.response.encryptedCookie(this.rememberMeKeyName, token.value!.release(), { - maxAge: rememberMeTokenAge, - httpOnly: true, - }) - } else { - this.#ctx.response.encryptedCookie(this.rememberMeKeyName, rememberMeCookie, { - maxAge: rememberMeTokenAge, - httpOnly: true, - }) - } - - return this.user! - } - - /** - * Silently attempt to authenticate the user. - * - * The method returns a boolean indicating if the authentication - * succeeded or failed. - */ - async check(): Promise { - try { - await this.authenticate() - return true - } catch (error) { - if (error instanceof AuthenticationException) { - return false - } - - throw error - } - } - - /** - * Logout user and revoke remember me token (if any) - */ - async logout() { - debug('session_auth: logging out') - const session = this.#getSession() - - /** - * Clear client side state - */ - session.forget(this.sessionKeyName) - this.#ctx.response.clearCookie(this.rememberMeKeyName) - - /** - * Notify the user has been logged out - */ - this.#emitter.emit('session_auth:logged_out', { - ctx: this.#ctx, - guardName: this.#name, - user: this.user || null, - sessionId: session.sessionId, - }) - - const rememberMeCookie = this.#ctx.request.encryptedCookie(this.rememberMeKeyName) - if (!rememberMeCookie || !this.#rememberMeTokenProvider) { - return - } - - debug('session_auth: decoding remember me token') - const decodedToken = RememberMeToken.decode(rememberMeCookie) - if (!decodedToken) { - return - } - - debug('session_auth: deleting remember me token') - await this.#rememberMeTokenProvider.deleteTokenBySeries(decodedToken.series) - } - - /** - * Returns the session state for the user to be - * logged-in as a client - */ - async authenticateAsClient( - user: UserProvider[typeof PROVIDER_REAL_USER] - ): Promise<{ session: Record }> { - const providerUser = await this.#userProvider.createUserForGuard(user) - const userId = providerUser.getId() - - debug('session_guard: returning client session for user id "%s"', userId) - return { - session: { - [this.sessionKeyName]: userId, - }, - } - } -} diff --git a/src/guards/session/main.ts b/src/guards/session/main.ts deleted file mode 100644 index d942b30..0000000 --- a/src/guards/session/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export { SessionGuard } from './guard.js' -export { RememberMeToken } from './remember_me_token.js' -export { sessionGuard, tokensProvider } from './define_config.js' diff --git a/src/guards/session/remember_me_token.ts b/src/guards/session/remember_me_token.ts deleted file mode 100644 index e1dfe19..0000000 --- a/src/guards/session/remember_me_token.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { createHash } from 'node:crypto' -import string from '@adonisjs/core/helpers/string' -import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' - -/** - * Remember me token represents a remember me token created - * for a peristed login flow. - */ -export class RememberMeToken { - /** - * Decodes a publicly shared token and return the series - * and the token value from it. - * - * Returns null when unable to decode the token because of - * invalid format or encoding. - */ - static decode(value: string): null | { series: string; value: string } { - if (typeof value !== 'string') { - return null - } - - const [series, ...tokenValue] = value.split('.') - if (!series || tokenValue.length === 0) { - return null - } - - const decodedSeries = base64.urlDecode(series) - const decodedValue = base64.urlDecode(tokenValue.join('.')) - if (!decodedSeries || !decodedValue) { - return null - } - - return { - series: decodedSeries, - value: decodedValue, - } - } - - /** - * Creates remember me token instance from persisted information. - * The returned token does not have timestamps defined, so make - * sure to define them. - */ - static createFromPersisted(userId: string | number, guard: string, series: string) { - return new RememberMeToken(userId, guard, series) - } - - /** - * Creates a new remember me token instance. Calling this - * method computes the token series, value and hash - */ - static create( - userId: string | number, - expiry: string | number, - guard: string, - size: number = 30 - ) { - const series = string.random(15) - const seed = string.random(size) - - const token = new RememberMeToken(userId, guard, series) - token.value = new Secret(`${base64.urlEncode(token.series)}.${base64.urlEncode(seed)}`) - token.hash = createHash('sha256').update(seed).digest('hex') - - token.createdAt = new Date() - token.updatedAt = new Date() - token.expiresAt = new Date() - token.expiresAt.setSeconds(token.createdAt.getSeconds() + string.seconds.parse(expiry)) - - return token - } - - /** - * Static name for the token to uniquely identify a - * bucket of tokens - */ - readonly type: 'remember_me_token' = 'remember_me_token' - - /** - * The series and seed is persisted inside the cookie and later - * splitted to perform the lookup. - */ - value?: Secret - - /** - * Date/time when the token instance was created - */ - declare createdAt: Date - - /** - * Date/time when the token was updated - */ - declare updatedAt: Date - - /** - * Hash is computed from the seed to later verify the validify - * of seed - */ - declare hash: string - - /** - * Timestamp at which the token will expire - */ - declare expiresAt: Date - - constructor( - /** - * Reference to the user id for whom the token - * is generated - */ - public userId: string | number, - - /** - * Guard for which the token is generated. This is to avoid - * cross guards using each others remember me tokens - */ - public guard: string, - - /** - * Series is a unique sequence to identify the - * token within database. It should be the - * primary/unique key - */ - public series: string - ) {} - - /** - * Refreshes the token's value, hash, updatedAt and - * expiresAt timestamps - */ - refresh(expiry: string | number, size: number = 30) { - const seed = string.random(size) - - /** - * Re-computing public value and hash - */ - this.hash = createHash('sha256').update(seed).digest('hex') - this.value = new Secret(`${base64.urlEncode(this.series)}.${base64.urlEncode(seed)}`) - - /** - * Updating expiry and updated_at timestamp - */ - this.updatedAt = new Date() - this.expiresAt = new Date() - this.expiresAt.setSeconds(this.updatedAt.getSeconds() + string.seconds.parse(expiry)) - } - - /** - * Verifies the value of a token against the pre-defined hash - */ - verify(value: string): boolean { - const newHash = createHash('sha256').update(value).digest('hex') - return safeEqual(this.hash, newHash) - } -} diff --git a/src/guards/session/token_providers/database.ts b/src/guards/session/token_providers/database.ts deleted file mode 100644 index 2513cab..0000000 --- a/src/guards/session/token_providers/database.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { RememberMeToken } from '../remember_me_token.js' -import type { RememberMeProviderContract } from '../types.js' -import { DatabaseTokenProvider } from '../../../core/token_providers/database.js' - -/** - * Representation of token within the database table - */ -type DatabaseTokenRow = { - series: string - user_id: string | number - type: string - guard: string - token: string - created_at: Date - updated_at: Date - expires_at: Date -} - -/** - * Remember me token provider to persist tokens inside the database - * using db query builder. - */ -export class DatabaseRememberTokenProvider - extends DatabaseTokenProvider - implements RememberMeProviderContract -{ - /** - * Prepares a token from the database result - */ - protected prepareToken(dbRow: DatabaseTokenRow): RememberMeToken | null { - const token = RememberMeToken.createFromPersisted(dbRow.user_id, dbRow.guard, dbRow.series) - token.hash = dbRow.token - token.guard = dbRow.guard - token.createdAt = - typeof dbRow.created_at === 'number' ? new Date(dbRow.created_at) : dbRow.created_at - token.updatedAt = - typeof dbRow.updated_at === 'number' ? new Date(dbRow.updated_at) : dbRow.updated_at - token.expiresAt = - typeof dbRow.expires_at === 'number' ? new Date(dbRow.expires_at) : dbRow.expires_at - - /** - * Ensure the token fetched from db is of same type. Otherwise - * return null - */ - if (dbRow.type !== token.type) { - return null - } - - /** - * Ensure the token is not expired - */ - if (token.expiresAt < new Date()) { - return null - } - - return token - } - - /** - * Converts the remember me token into a database row - */ - protected parseToken(token: RememberMeToken): DatabaseTokenRow { - return { - series: token.series, - user_id: token.userId, - type: token.type, - token: token.hash, - guard: token.guard, - created_at: token.createdAt, - updated_at: token.updatedAt, - expires_at: token.expiresAt, - } - } -} diff --git a/src/guards/session/types.ts b/src/guards/session/types.ts deleted file mode 100644 index 367cd62..0000000 --- a/src/guards/session/types.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { Exception } from '@poppinss/utils' -import type { HttpContext } from '@adonisjs/core/http' - -import type { RememberMeToken } from './remember_me_token.js' -import type { - UserProviderContract, - TokenProviderContract, - DatabaseTokenProviderOptions, -} from '../../core/types.js' - -/** - * The SessionUserProvider is used to lookup a user for session based authentication. - */ -export interface SessionUserProviderContract extends UserProviderContract {} - -/** - * The RememberMeProviderContract is used to persist and lookup tokens for - * session based authentication with remember me option. - */ -export interface RememberMeProviderContract extends TokenProviderContract {} - -/** - * Config accepted by the session guard - */ -export type SessionGuardConfig = { - /** - * The expiry for the remember me cookie. - * - * Defaults to "5 years" - */ - rememberMeTokenAge?: string | number -} - -/** - * Events emitted by the session guard - */ -export type SessionGuardEvents = { - /** - * The event is emitted when the user credentials - * have been verified successfully. - */ - 'session_auth:credentials_verified': { - ctx: HttpContext - guardName: string - uid: string - user: User - } - - /** - * The event is emitted when unable to login the - * user. - */ - 'session_auth:login_failed': { - ctx: HttpContext - guardName: string - error: Exception - } - - /** - * The event is emitted when login is attempted for - * a given user. - */ - 'session_auth:login_attempted': { - ctx: HttpContext - guardName: string - user: User - } - - /** - * The event is emitted when user has been logged in - * successfully - */ - 'session_auth:login_succeeded': { - ctx: HttpContext - guardName: string - user: User - sessionId: string - rememberMeToken?: RememberMeToken - } - - /** - * Attempting to authenticate the user - */ - 'session_auth:authentication_attempted': { - ctx: HttpContext - guardName: string - sessionId: string - } - - /** - * Authentication was successful - */ - 'session_auth:authentication_succeeded': { - ctx: HttpContext - guardName: string - user: User - sessionId: string - rememberMeToken?: RememberMeToken - } - - /** - * Authentication failed - */ - 'session_auth:authentication_failed': { - ctx: HttpContext - guardName: string - error: Exception - sessionId: string - } - - /** - * The event is emitted when user has been logged out - * sucessfully - */ - 'session_auth:logged_out': { - ctx: HttpContext - guardName: string - user: User | null - sessionId: string - } -} - -/** - * Options accepted by the database implementation of the - * RememberMeProvider - */ -export type DatabaseRememberMeProviderOptions = DatabaseTokenProviderOptions diff --git a/src/auth/middleware/initialize_auth_middleware.ts b/src/middleware/initialize_auth_middleware.ts similarity index 100% rename from src/auth/middleware/initialize_auth_middleware.ts rename to src/middleware/initialize_auth_middleware.ts diff --git a/src/auth/plugins/japa/api_client.ts b/src/plugins/japa/api_client.ts similarity index 92% rename from src/auth/plugins/japa/api_client.ts rename to src/plugins/japa/api_client.ts index d99dd2f..ae01537 100644 --- a/src/auth/plugins/japa/api_client.ts +++ b/src/plugins/japa/api_client.ts @@ -24,8 +24,8 @@ declare module '@japa/api-client' { } /** - * Login a user using the default authentication - * guard when making an API call + * Login a user using the default authentication guard + * when making an API call */ loginAs(user: { [K in keyof Authenticators]: Authenticators[K] extends GuardFactory @@ -64,6 +64,10 @@ export const authApiClient = (app: ApplicationService) => { const pluginFn: PluginFn = function () { debug('installing auth api client plugin') + /** + * Login a user using the default authentication guard + * when making an API call + */ ApiRequest.macro('loginAs', function (this: ApiRequest, user) { this.authData = { guard: '__default__', @@ -72,6 +76,9 @@ export const authApiClient = (app: ApplicationService) => { return this }) + /** + * Define the authentication guard for login + */ ApiRequest.macro('withGuard', function < K extends keyof Authenticators, Self extends ApiRequest, diff --git a/src/auth/plugins/japa/browser_client.ts b/src/plugins/japa/browser_client.ts similarity index 88% rename from src/auth/plugins/japa/browser_client.ts rename to src/plugins/japa/browser_client.ts index 7a64227..8853f25 100644 --- a/src/auth/plugins/japa/browser_client.ts +++ b/src/plugins/japa/browser_client.ts @@ -21,9 +21,8 @@ import type { Authenticators, GuardContract, GuardFactory } from '../../types.js declare module 'playwright' { export interface BrowserContext { /** - * Login a user using the default authentication - * guard when using the browser context to - * make page visits + * Login a user using the default authentication guard when + * using the browser context to make page visits */ loginAs(user: { [K in keyof Authenticators]: Authenticators[K] extends GuardFactory @@ -53,6 +52,10 @@ declare module 'playwright' { } } +/** + * Browser API client to authenticate users when making + * HTTP requests using the Japa Browser client. + */ export const authBrowserClient = (app: ApplicationService) => { const pluginFn: PluginFn = async function () { debug('installing auth browser client plugin') @@ -61,6 +64,10 @@ export const authBrowserClient = (app: ApplicationService) => { decoratorsCollection.register({ context(context) { + /** + * Define the authentication guard for login and perform + * login + */ context.withGuard = function (guardName) { return { async loginAs(user) { @@ -89,6 +96,10 @@ export const authBrowserClient = (app: ApplicationService) => { } } + /** + * Login a user using the default authentication guard when + * using the browser context to make page visits + */ context.loginAs = async function (user) { const client = auth.createAuthenticatorClient() const guard = client.use() as GuardContract diff --git a/src/auth/symbols.ts b/src/symbols.ts similarity index 100% rename from src/auth/symbols.ts rename to src/symbols.ts diff --git a/src/auth/types.ts b/src/types.ts similarity index 73% rename from src/auth/types.ts rename to src/types.ts index 08006be..ce0a1e5 100644 --- a/src/auth/types.ts +++ b/src/types.ts @@ -14,7 +14,8 @@ import type { AuthManager } from './auth_manager.js' import type { GUARD_KNOWN_EVENTS } from './symbols.js' /** - * The client response for authentication. + * Authentication response to login a user as a client. + * This response is used by Japa plugins */ export interface AuthClientResponse { headers?: Record @@ -23,9 +24,15 @@ export interface AuthClientResponse { } /** - * A set of properties a guard must implement. + * A set of properties a guard must implement to authenticate + * incoming HTTP requests */ export interface GuardContract { + /** + * A unique name for the guard driver + */ + readonly driverName: string + /** * Reference to the currently authenticated user */ @@ -49,29 +56,23 @@ export interface GuardContract { authenticationAttempted: boolean /** - * Check if the current request has been - * authenticated without throwing an - * exception + * Authenticates the current request and throws an + * exception if the request is not authenticated. */ - check(): Promise - - /** - * The method is used to authenticate the user as - * client. This method should return cookies, - * headers, or session state. - */ - authenticateAsClient(user: User): Promise + authenticate(): Promise /** - * Authenticates the current request and throws - * an exception if the request is not authenticated. + * Check if the current request has been authenticated + * without throwing an exception. */ - authenticate(): Promise + check(): Promise /** - * A unique name for the guard driver + * The method is used to authenticate the user as client. + * This method should return cookies, headers, or + * session state. */ - driverName: string + authenticateAsClient(user: User): Promise /** * Aymbol for infer the events emitted by a specific @@ -81,12 +82,20 @@ export interface GuardContract { } /** - * The authenticator guard factory method is called by the - * Authenticator class to create an instance of a specific - * guard during an HTTP request + * The guard factory method is called by the create an instance + * of a guard during an HTTP request */ export type GuardFactory = (ctx: HttpContext) => GuardContract +/** + * Config provider for registering guards. The "name" property + * is reference to the object key to which the guard is + * assigned. + */ +export type GuardConfigProvider = { + resolver: (name: string, app: ApplicationService) => Promise +} + /** * Authenticators are inferred inside the user application * from the config file @@ -131,10 +140,3 @@ export interface AuthService extends AuthManager< Authenticators extends Record ? Authenticators : never > {} - -/** - * Config provider for exporting guard - */ -export type GuardConfigProvider = { - resolver: (name: string, app: ApplicationService) => Promise -} diff --git a/tests/auth/auth_manager.spec.ts b/tests/auth/auth_manager.spec.ts index a8ed3e4..cd5dee8 100644 --- a/tests/auth/auth_manager.spec.ts +++ b/tests/auth/auth_manager.spec.ts @@ -10,22 +10,19 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { createEmitter } from '../helpers.js' -import { AuthManager } from '../../src/auth/auth_manager.js' -import { Authenticator } from '../../src/auth/authenticator.js' -import { SessionGuardFactory } from '../../factories/guards/session/guard_factory.js' -import { AuthenticatorClient } from '../../src/auth/authenticator_client.js' +import { AuthManager } from '../../src/auth_manager.js' +import { FakeGuard } from '../../factories/auth/main.js' +import { Authenticator } from '../../src/authenticator.js' +import { AuthenticatorClient } from '../../src/authenticator_client.js' test.group('Auth manager', () => { test('create authenticator from auth manager', async ({ assert, expectTypeOf }) => { - const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const authManager = new AuthManager({ default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) @@ -35,14 +32,10 @@ test.group('Auth manager', () => { }) test('create authenticator client from auth manager', async ({ assert, expectTypeOf }) => { - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const authManager = new AuthManager({ default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) diff --git a/tests/auth/authenticator.spec.ts b/tests/auth/authenticator.spec.ts index eafc225..dfd31e0 100644 --- a/tests/auth/authenticator.spec.ts +++ b/tests/auth/authenticator.spec.ts @@ -9,23 +9,19 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { Authenticator } from '../../src/auth/authenticator.js' -import { FactoryUser } from '../../factories/core/lucid_user_provider.js' -import { createDatabase, createEmitter, createTables } from '../helpers.js' -import { SessionGuardFactory } from '../../factories/guards/session/guard_factory.js' +import { Authenticator } from '../../src/authenticator.js' +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import { FakeGuard, FakeUser } from '../../factories/auth/main.js' test.group('Authenticator', () => { test('create authenticator with guards', async ({ assert, expectTypeOf }) => { - const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const authenticator = new Authenticator(ctx, { default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) @@ -34,78 +30,75 @@ test.group('Authenticator', () => { }) test('access guard using its name', async ({ assert, expectTypeOf }) => { - const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) const authenticator = new Authenticator(ctx, { default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) const webGuard = authenticator.use('web') - assert.strictEqual(webGuard, sessionGuard) + assert.instanceOf(webGuard, FakeGuard) assert.equal(authenticator.defaultGuard, 'web') - assert.equal(webGuard.driverName, 'session') + assert.equal(webGuard.driverName, 'fake') assert.strictEqual(authenticator.use('web'), authenticator.use('web')) - expectTypeOf(webGuard.user).toMatchTypeOf() + expectTypeOf(webGuard.user).toMatchTypeOf() }) test('authenticate using the default guard', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() const authenticator = new Authenticator(ctx, { default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) - await sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - await authenticator.authenticate() - }) + await authenticator.authenticate() - assert.instanceOf(authenticator.user, FactoryUser) - assert.equal(authenticator.user!.id, user.id) - expectTypeOf(authenticator.user).toMatchTypeOf() - expectTypeOf(authenticator.getUserOrFail()).toMatchTypeOf() + assert.equal(authenticator.user!.id, 1) + expectTypeOf(authenticator.user).toMatchTypeOf() + expectTypeOf(authenticator.getUserOrFail()).toMatchTypeOf() assert.equal(authenticator.authenticatedViaGuard, 'web') assert.isTrue(authenticator.isAuthenticated) assert.isTrue(authenticator.authenticationAttempted) }) - test('authenticate using the guard instance', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() + test('check authentication using the default guard', async ({ assert, expectTypeOf }) => { const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() const authenticator = new Authenticator(ctx, { default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) - await sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - await authenticator.use().authenticate() + await authenticator.check() + + assert.equal(authenticator.user!.id, 1) + expectTypeOf(authenticator.user).toMatchTypeOf() + expectTypeOf(authenticator.getUserOrFail()).toMatchTypeOf() + assert.equal(authenticator.authenticatedViaGuard, 'web') + assert.isTrue(authenticator.isAuthenticated) + assert.isTrue(authenticator.authenticationAttempted) + }) + + test('authenticate using the guard instance', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => new FakeGuard(), + }, }) + const user = await authenticator.use().authenticate() + + assert.equal(user.id, 1) assert.isUndefined(authenticator.user) assert.isUndefined(authenticator.authenticatedViaGuard) assert.isFalse(authenticator.isAuthenticated) @@ -113,60 +106,68 @@ test.group('Authenticator', () => { }) test('access properties without authenticating user', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const authenticator = new Authenticator(ctx, { default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) - await sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - }) - assert.isUndefined(authenticator.user) assert.isUndefined(authenticator.authenticatedViaGuard) assert.isFalse(authenticator.isAuthenticated) assert.isFalse(authenticator.authenticationAttempted) - assert.throws(() => authenticator.getUserOrFail(), 'Invalid or expired authentication session') + assert.throws( + () => authenticator.getUserOrFail(), + 'Cannot access authenticated user. Please call "auth.authenticate" method first.' + ) }) test('throw error when unable to authenticate', async ({ assert }) => { - assert.plan(4) - - const db = await createDatabase() - await createTables(db) + assert.plan(5) - const emitter = createEmitter() const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const authenticator = new Authenticator(ctx, { default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) + authenticator.use('web').authenticate = async function () { + this.authenticationAttempted = true + return this.getUserOrFail() + } + try { - await sessionMiddleware.handle(ctx, async () => { - await authenticator.authenticateUsing() - }) + await authenticator.authenticateUsing() } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) assert.equal(error.message, 'Unauthorized access') - assert.equal(error.guardDriverName, 'session') + assert.equal(error.guardDriverName, 'fake') + } + + assert.isFalse(authenticator.isAuthenticated) + assert.isTrue(authenticator.authenticationAttempted) + }) + + test('do not throw error when unable to authenticate via check method', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const authenticator = new Authenticator(ctx, { + default: 'web', + guards: { + web: () => new FakeGuard(), + }, + }) + + authenticator.use('web').authenticate = async function () { + this.authenticationAttempted = true + return this.getUserOrFail() } + const isAuthenticated = await authenticator.check() + assert.isFalse(isAuthenticated) assert.isFalse(authenticator.isAuthenticated) assert.isTrue(authenticator.authenticationAttempted) }) diff --git a/tests/auth/authenticator_client.spec.ts b/tests/auth/authenticator_client.spec.ts index d43fbe4..8dd9791 100644 --- a/tests/auth/authenticator_client.spec.ts +++ b/tests/auth/authenticator_client.spec.ts @@ -8,23 +8,15 @@ */ import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' - -import { FactoryUser } from '../../factories/core/lucid_user_provider.js' -import { createDatabase, createEmitter, createTables } from '../helpers.js' -import { SessionGuardFactory } from '../../factories/guards/session/guard_factory.js' -import { AuthenticatorClient } from '../../src/auth/authenticator_client.js' +import { FakeGuard, FakeUser } from '../../factories/auth/main.js' +import { AuthenticatorClient } from '../../src/authenticator_client.js' test.group('Authenticator client', () => { test('create authenticator client with guards', async ({ assert, expectTypeOf }) => { - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const client = new AuthenticatorClient({ default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) @@ -33,46 +25,30 @@ test.group('Authenticator client', () => { }) test('access guard using its name', async ({ assert, expectTypeOf }) => { - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const client = new AuthenticatorClient({ default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) const webGuard = client.use('web') - assert.strictEqual(webGuard, sessionGuard) + assert.instanceOf(webGuard, FakeGuard) assert.equal(client.defaultGuard, 'web') - assert.equal(webGuard.driverName, 'session') + assert.equal(webGuard.driverName, 'fake') assert.strictEqual(client.use('web'), client.use('web')) assert.strictEqual(client.use(), client.use('web')) - expectTypeOf(webGuard.user).toMatchTypeOf() + expectTypeOf(webGuard.user).toMatchTypeOf() }) test('call authenticateAsClient via client', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const client = new AuthenticatorClient({ default: 'web', guards: { - web: () => sessionGuard, + web: () => new FakeGuard(), }, }) - assert.deepEqual(await client.use('web').authenticateAsClient(user), { - session: { - auth_web: user.id, - }, - }) + await assert.rejects(() => client.use('web').authenticateAsClient({ id: 1 }), 'Not supported') }) }) diff --git a/tests/auth/configure.spec.ts b/tests/auth/configure.ts similarity index 100% rename from tests/auth/configure.spec.ts rename to tests/auth/configure.ts diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts index be3976c..57940da 100644 --- a/tests/auth/define_config.spec.ts +++ b/tests/auth/define_config.spec.ts @@ -10,96 +10,49 @@ import { test } from '@japa/runner' import { ApplicationService } from '@adonisjs/core/types' import { AppFactory } from '@adonisjs/core/factories/app' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { HashManagerFactory } from '@adonisjs/core/factories/hash' -import { createEmitter } from '../helpers.js' -import { AuthManager } from '../../src/auth/auth_manager.js' -import { Authenticator } from '../../src/auth/authenticator.js' -import { FactoryUser } from '../../factories/core/lucid_user_provider.js' -import { sessionGuard } from '../../src/guards/session/define_config.js' -import { defineConfig, providers } from '../../src/auth/define_config.js' -import { LucidUserProvider } from '../../src/auth/user_providers/main.js' +import { AuthManager } from '../../src/auth_manager.js' +import { FakeGuard } from '../../factories/auth/main.js' +import { defineConfig } from '../../src/define_config.js' +import { HttpContextFactory } from '@adonisjs/core/factories/http' const BASE_URL = new URL('./', import.meta.url) const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService await app.init() -test.group('Define config | providers', () => { - test('configure lucid provider', async ({ assert }) => { - const lucidConfigProvider = providers.lucid({ - model: async () => { - return { - default: FactoryUser, - } - }, - passwordColumnName: 'password', - uids: ['email'], - }) - - app.container.bind('hash', () => new HashManagerFactory().create()) - - const lucidProvider = await lucidConfigProvider.resolver(app) - assert.instanceOf(lucidProvider, LucidUserProvider) - }) -}) - test.group('Define config', () => { - test('define config for auth manager', async ({ assert }) => { - const lucidConfigProvider = providers.lucid({ - model: async () => { - return { - default: FactoryUser, - } - }, - passwordColumnName: 'password', - uids: ['email'], - }) - + test('define and resolve config for the auth manager', async ({ assert }) => { const authConfigProvider = defineConfig({ default: 'web', guards: { - web: sessionGuard({ - provider: lucidConfigProvider, - }), + web: () => new FakeGuard(), }, }) - - app.container.bind('emitter', () => createEmitter() as any) + const ctx = new HttpContextFactory().create() const authConfig = await authConfigProvider.resolver(app) const authManager = new AuthManager(authConfig) assert.instanceOf(authManager, AuthManager) + assert.instanceOf(authManager.createAuthenticator(ctx).use('web'), FakeGuard) }) - test('create auth object from auth manager', async ({ assert, expectTypeOf }) => { - const lucidConfigProvider = providers.lucid({ - model: async () => { - return { - default: FactoryUser, - } - }, - passwordColumnName: 'password', - uids: ['email'], - }) - + test('resolve guard registered as provider', async ({ assert }) => { const authConfigProvider = defineConfig({ default: 'web', guards: { - web: sessionGuard({ - provider: lucidConfigProvider, - }), + web: { + async resolver(name) { + assert.equal(name, 'web') + return () => new FakeGuard() + }, + }, }, }) - app.container.bind('emitter', () => createEmitter() as any) - const ctx = new HttpContextFactory().create() const authConfig = await authConfigProvider.resolver(app) const authManager = new AuthManager(authConfig) - const auth = authManager.createAuthenticator(ctx) - - assert.instanceOf(auth, Authenticator) - expectTypeOf(auth.use).parameters.toMatchTypeOf<['web'?]>() + assert.instanceOf(authManager, AuthManager) + assert.instanceOf(authManager.createAuthenticator(ctx).use('web'), FakeGuard) }) }) diff --git a/tests/auth/errors.spec.ts b/tests/auth/errors.spec.ts index 975bf9a..7cd31ac 100644 --- a/tests/auth/errors.spec.ts +++ b/tests/auth/errors.spec.ts @@ -8,14 +8,16 @@ */ import { test } from '@japa/runner' +import { I18nManagerFactory } from '@adonisjs/i18n/factories' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { AuthenticationException, InvalidCredentialsException } from '../../src/auth/errors.js' -test.group('Errors | AuthenticationException', () => { - test('handle session guard exception with a redirect', async ({ assert }) => { +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' + +test.group('Errors | E_UNAUTHORIZED_ACCESS | session', () => { + test('report error via flash messages and redirect', async ({ assert }) => { const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = new AuthenticationException('Unauthorized access', { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { guardDriverName: 'session', }) @@ -25,17 +27,15 @@ test.group('Errors | AuthenticationException', () => { }) assert.deepEqual(ctx.session.responseFlashMessages.all(), { - errorsBag: { 'auth.authenticate': ['Unauthorized access'] }, + errorsBag: { E_UNAUTHORIZED_ACCESS: 'Unauthorized access' }, input: {}, }) assert.equal(ctx.response.getHeader('location'), '/') }) - test('handle session guard exception with a redirect to a custom location', async ({ - assert, - }) => { + test('redirect to a custom location', async ({ assert }) => { const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = new AuthenticationException('Unauthorized access', { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { guardDriverName: 'session', redirectTo: '/login', }) @@ -46,30 +46,26 @@ test.group('Errors | AuthenticationException', () => { }) assert.deepEqual(ctx.session.responseFlashMessages.all(), { - errorsBag: { 'auth.authenticate': ['Unauthorized access'] }, + errorsBag: { E_UNAUTHORIZED_ACCESS: 'Unauthorized access' }, input: {}, }) assert.equal(ctx.response.getHeader('location'), '/login') }) - test('handle session guard exception with JSON response', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = new AuthenticationException('Unauthorized access', { + test('respond with json', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { guardDriverName: 'session', - redirectTo: '/login', }) const ctx = new HttpContextFactory().create() /** - * The accept header will force a JSON response + * Force JSON response */ ctx.request.request.headers.accept = 'application/json' + await error.handle(error, ctx) - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) - + assert.isUndefined(ctx.response.getHeader('location')) assert.deepEqual(ctx.response.getBody(), { errors: [ { @@ -77,154 +73,103 @@ test.group('Errors | AuthenticationException', () => { }, ], }) - assert.isUndefined(ctx.response.getHeader('location')) - assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) }) - test('handle session guard exception with JSONAPI response', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = new AuthenticationException('Unauthorized access', { + test('respond with JSONAPI response', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { guardDriverName: 'session', - redirectTo: '/login', }) const ctx = new HttpContextFactory().create() /** - * The accept header will force a JSONAPI response + * Force JSONAPI response */ ctx.request.request.headers.accept = 'application/vnd.api+json' + await error.handle(error, ctx) - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) - + assert.isUndefined(ctx.response.getHeader('location')) assert.deepEqual(ctx.response.getBody(), { errors: [ { title: 'Unauthorized access', - code: 'auth.authenticate', + code: 'E_UNAUTHORIZED_ACCESS', }, ], }) - assert.isUndefined(ctx.response.getHeader('location')) - assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) - }) - - test('send plain text response when there is no renderer for a guard', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = new AuthenticationException('Unauthorized access', { - guardDriverName: 'foo', - redirectTo: '/login', - status: 401, - }) - - const ctx = new HttpContextFactory().create() - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) - - assert.equal(ctx.response.getStatus(), 401) - assert.equal(ctx.response.getBody(), 'Unauthorized access') }) - test('handle basic auth exception with a prompt', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = AuthenticationException.E_INVALID_BASIC_AUTH_CREDENTIALS() - - const ctx = new HttpContextFactory().create() - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) - - assert.equal( - ctx.response.getHeader('WWW-Authenticate'), - `Basic realm="Authenticate", charset="UTF-8"` - ) - }) -}) - -test.group('Errors | InvalidCredentialsException', () => { - test('handle session guard exception with a redirect', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('session') - - const ctx = new HttpContextFactory().create() - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) - - assert.deepEqual(ctx.session.responseFlashMessages.all(), { - errorsBag: { 'auth.login': ['Invalid credentials'] }, - input: {}, + test('translate error message using i18n', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: 'session', }) - assert.equal(ctx.response.getHeader('location'), '/') - }) - - test('handle session guard exception with a JSON response', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('session') + const i18nManager = new I18nManagerFactory() + .merge({ + config: { + loaders: [ + () => { + return { + async load() { + return { + en: { + 'errors.E_UNAUTHORIZED_ACCESS': 'Access denied', + }, + } + }, + } + }, + ], + }, + }) + .create() const ctx = new HttpContextFactory().create() + await i18nManager.loadTranslations() + ctx.i18n = i18nManager.locale('en') /** - * The accept header will force a JSON response + * Force JSON response */ ctx.request.request.headers.accept = 'application/json' + await error.handle(error, ctx) - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) - + assert.isUndefined(ctx.response.getHeader('location')) assert.deepEqual(ctx.response.getBody(), { errors: [ { - message: 'Invalid credentials', + message: 'Access denied', }, ], }) - assert.isUndefined(ctx.response.getHeader('location')) - assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) }) +}) - test('handle session guard exception with a JSONAPI response', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('session') +test.group('Errors | E_UNAUTHORIZED_ACCESS | basic auth', () => { + test('handle basic auth exception with a prompt', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: 'basic_auth', + }) const ctx = new HttpContextFactory().create() + await error.handle(error, ctx) - /** - * The accept header will force a JSON response - */ - ctx.request.request.headers.accept = 'application/vnd.api+json' - - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) - - assert.deepEqual(ctx.response.getBody(), { - errors: [ - { - title: 'Invalid credentials', - code: 'auth.login', - }, - ], - }) - assert.isUndefined(ctx.response.getHeader('location')) - assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) + assert.equal( + ctx.response.getHeader('WWW-Authenticate'), + `Basic realm="Authenticate", charset="UTF-8"` + ) }) +}) - test('respond with plain text when there is no renderer for guard', async ({ assert }) => { - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = InvalidCredentialsException.E_INVALID_CREDENTIALS('foo') +test.group('Errors | E_UNAUTHORIZED_ACCESS | unknown guard', () => { + test('send plain text response', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: 'foo', + }) const ctx = new HttpContextFactory().create() - await sessionMiddleware.handle(ctx, async () => { - return error.handle(error, ctx) - }) + await error.handle(error, ctx) - assert.equal(ctx.response.getBody(), 'Invalid credentials') - assert.isUndefined(ctx.response.getHeader('location')) - assert.deepEqual(ctx.session.responseFlashMessages.all(), {}) + assert.equal(ctx.response.getStatus(), 401) + assert.equal(ctx.response.getBody(), 'Unauthorized access') }) }) diff --git a/tests/core/token_providers/database.spec.ts b/tests/core/token_providers/database.spec.ts deleted file mode 100644 index 453f7dc..0000000 --- a/tests/core/token_providers/database.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables } from '../../helpers.js' -import { DatabaseTokenProviderFactory } from '../../../factories/core/database_token_factory.js' - -test.group('Database token provider | createToken', () => { - test('persist a token to the database', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = { series: '12345', hash: '12345_hash', user_id: 1 } - const databaseProvider = new DatabaseTokenProviderFactory().create(db) - - await databaseProvider.createToken(token) - const tokens = await db.query().from('test_tokens') - - assert.lengthOf(tokens, 1) - assert.equal(tokens[0].user_id, token.user_id) - assert.equal(tokens[0].hash, token.hash) - assert.equal(tokens[0].series, token.series) - }) - - test('find token by series', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = { series: '12345', hash: '12345_hash', user_id: 1 } - const databaseProvider = new DatabaseTokenProviderFactory().create(db) - - await databaseProvider.createToken(token) - const freshToken = await databaseProvider.getTokenBySeries(token.series) - - assert.deepEqual(freshToken, token) - }) - - test("return null when token doesn't exists", async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const databaseProvider = new DatabaseTokenProviderFactory().create(db) - assert.isNull(await databaseProvider.getTokenBySeries('foobar')) - }) - - test('update token by series', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = { series: '12345', hash: '12345_hash', user_id: 1 } - const databaseProvider = new DatabaseTokenProviderFactory().create(db) - - await databaseProvider.createToken(token) - token.hash = '12345_hash_updated' - await databaseProvider.updateTokenBySeries(token.series, token) - - const tokens = await db.query().from('test_tokens') - assert.lengthOf(tokens, 1) - assert.equal(tokens[0].hash, '12345_hash_updated') - }) - - test('delete token by series', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = { series: '12345', hash: '12345_hash', user_id: 1 } - const databaseProvider = new DatabaseTokenProviderFactory().create(db) - - await databaseProvider.createToken(token) - assert.isNotNull(await databaseProvider.getTokenBySeries(token.series)) - - await databaseProvider.deleteTokenBySeries(token.series) - assert.isNull(await databaseProvider.getTokenBySeries(token.series)) - }) -}) diff --git a/tests/core/user_providers/lucid/create_user_for_guard.spec.ts b/tests/core/user_providers/lucid/create_user_for_guard.spec.ts deleted file mode 100644 index a39e163..0000000 --- a/tests/core/user_providers/lucid/create_user_for_guard.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables } from '../../../helpers.js' -import { - FactoryUser, - LucidUserProviderFactory, -} from '../../../../factories/core/lucid_user_provider.js' - -test.group('Lucid user provider | createUserForGuard', () => { - test('create a guard user from a model instance', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const user = await FactoryUser.create({ - email: 'foo@bar.com', - username: 'foo', - password: 'secret', - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const providerUser = await lucidUserProvider.createUserForGuard(user) - - expectTypeOf(providerUser.getOriginal()).toMatchTypeOf>() - assert.instanceOf(providerUser.getOriginal(), FactoryUser) - assert.isFalse(providerUser.getOriginal().$isNew) - assert.equal(providerUser.getId(), 1) - }) - - test('return error when user is not an instance of Model', async () => { - const db = await createDatabase() - await createTables(db) - - const lucidUserProvider = new LucidUserProviderFactory().create() - await lucidUserProvider.createUserForGuard({} as any) - }).throws('Invalid user object. It must be an instance of the "FactoryUser" model') - - test('return error when user primary key is missing', async () => { - const db = await createDatabase() - await createTables(db) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const user = await lucidUserProvider.createUserForGuard(new FactoryUser()) - user.getId() - }).throws( - 'Cannot use "FactoryUser" model for authentication. The value of column "id" is undefined or null' - ) -}) diff --git a/tests/core/user_providers/lucid/find_by_id.spec.ts b/tests/core/user_providers/lucid/find_by_id.spec.ts deleted file mode 100644 index 7d99181..0000000 --- a/tests/core/user_providers/lucid/find_by_id.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables } from '../../../helpers.js' -import { - FactoryUser, - LucidUserProviderFactory, -} from '../../../../factories/core/lucid_user_provider.js' - -test.group('Lucid user provider | findById', () => { - test('find a user using primary key', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.create({ email: 'foo@bar.com', username: 'foo', password: 'secret' }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const userById = await lucidUserProvider.findById(1) - - expectTypeOf(userById!.getOriginal()).toMatchTypeOf>() - assert.instanceOf(userById!.getOriginal(), FactoryUser) - assert.isFalse(userById!.getOriginal().$isNew) - assert.equal(userById!.getId(), 1) - }) - - test('return null when unable to find user by id', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const userById = await lucidUserProvider.findById(1) - - assert.isNull(userById) - }) -}) diff --git a/tests/core/user_providers/lucid/find_by_uid.spec.ts b/tests/core/user_providers/lucid/find_by_uid.spec.ts deleted file mode 100644 index 64d8098..0000000 --- a/tests/core/user_providers/lucid/find_by_uid.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createDatabase, createTables } from '../../../helpers.js' -import { - FactoryUser, - LucidUserProviderFactory, -} from '../../../../factories/core/lucid_user_provider.js' - -test.group('Lucid user provider | findByUid', () => { - test('find a user for login using uids', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.create({ email: 'foo@bar.com', username: 'foo', password: 'secret' }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const userByEmail = await lucidUserProvider.findByUid('foo@bar.com') - const userByUsername = await lucidUserProvider.findByUid('foo@bar.com') - - expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf>() - assert.instanceOf(userByEmail!.getOriginal(), FactoryUser) - assert.isFalse(userByEmail!.getOriginal().$isNew) - assert.equal(userByEmail!.getId(), 1) - - expectTypeOf(userByUsername!.getOriginal()).toMatchTypeOf>() - assert.instanceOf(userByUsername!.getOriginal(), FactoryUser) - assert.isFalse(userByUsername!.getOriginal().$isNew) - assert.equal(userByUsername!.getId(), 1) - }) - - test('customize user lookup by defining "getUserForAuth" method', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class CustomUser extends FactoryUser { - static async getUserForAuth(uids: string[], value: number | string) { - assert.deepEqual(uids, ['email']) - assert.equal(value, 'foo@bar.com') - return null - } - } - - const lucidUserProvider = new LucidUserProviderFactory().createForModel({ - model: async () => { - return { - default: CustomUser, - } - }, - passwordColumnName: 'password', - uids: ['email'], - }) - - const userByEmail = await lucidUserProvider.findByUid('foo@bar.com') - assert.isNull(userByEmail) - }) - - test('create provider user from value returned by "getUserForAuth"', async ({ - assert, - expectTypeOf, - }) => { - const db = await createDatabase() - await createTables(db) - - class CustomUser extends FactoryUser { - static async getUserForAuth(uids: string[], value: number | string) { - const user = await this.query().where(uids[0], value).first() - return user - } - } - - const lucidUserProvider = new LucidUserProviderFactory().createForModel({ - model: async () => { - return { - default: CustomUser, - } - }, - passwordColumnName: 'password', - uids: ['email'], - }) - await CustomUser.create({ email: 'foo@bar.com', username: 'foo', password: 'secret' }) - - const userByEmail = await lucidUserProvider.findByUid('foo@bar.com') - const userByUsername = await lucidUserProvider.findByUid('foo@bar.com') - - expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf>() - assert.instanceOf(userByEmail!.getOriginal(), FactoryUser) - assert.isFalse(userByEmail!.getOriginal().$isNew) - assert.equal(userByEmail!.getId(), 1) - - expectTypeOf(userByUsername!.getOriginal()).toMatchTypeOf>() - assert.instanceOf(userByUsername!.getOriginal(), FactoryUser) - assert.isFalse(userByUsername!.getOriginal().$isNew) - assert.equal(userByUsername!.getId(), 1) - }) - - test('return null when unable to find user by uid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const lucidUserProvider = new LucidUserProviderFactory().create() - - assert.isNull(await lucidUserProvider.findByUid('foo@bar.com')) - assert.isNull(await lucidUserProvider.findByUid('foo')) - }) -}) diff --git a/tests/core/user_providers/lucid/verify_credentials.spec.ts b/tests/core/user_providers/lucid/verify_credentials.spec.ts deleted file mode 100644 index ccd2858..0000000 --- a/tests/core/user_providers/lucid/verify_credentials.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import convertHrtime from 'convert-hrtime' -import { createDatabase, createTables, getHasher } from '../../../helpers.js' -import { - FactoryUser, - LucidUserProviderFactory, -} from '../../../../factories/core/lucid_user_provider.js' - -test.group('Lucid user provider | verifyCredentials', () => { - test('return user when email and password are correct', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.create({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const userByEmail = await lucidUserProvider.verifyCredentials('foo@bar.com', 'secret') - - expectTypeOf(userByEmail!.getOriginal()).toMatchTypeOf>() - assert.instanceOf(userByEmail!.getOriginal(), FactoryUser) - assert.isFalse(userByEmail!.getOriginal().$isNew) - assert.equal(userByEmail!.getId(), 1) - }) - - test('return null when password is invalid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.create({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const userByEmail = await lucidUserProvider.verifyCredentials('foo@bar.com', 'supersecret') - assert.isNull(userByEmail) - }) - - test('return null when email is incorrect', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.create({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - const userByEmail = await lucidUserProvider.verifyCredentials('baz@bar.com', 'secret') - assert.isNull(userByEmail) - }) - - test('throw error when password is missing', async () => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.create({ - email: 'foo@bar.com', - username: 'foo', - password: null, - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - await lucidUserProvider.verifyCredentials('foo@bar.com', 'secret') - }).throws( - 'Cannot verify password during login. The value of column "password" is undefined or null' - ) - - test('prevent timing attacks when email or password are invalid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - await FactoryUser.create({ - email: 'foo@bar.com', - username: 'foo', - password: await getHasher().make('secret'), - }) - - const lucidUserProvider = new LucidUserProviderFactory().create() - - let startTime = process.hrtime.bigint() - await lucidUserProvider.verifyCredentials('baz@bar.com', 'secret') - const invalidEmailTime = convertHrtime(process.hrtime.bigint() - startTime) - - startTime = process.hrtime.bigint() - await lucidUserProvider.verifyCredentials('foo@bar.com', 'supersecret') - const invalidPasswordTime = convertHrtime(process.hrtime.bigint() - startTime) - - /** - * Same timing within the range of 10 milliseconds is acceptable - */ - assert.isBelow(Math.abs(invalidPasswordTime.seconds - invalidEmailTime.seconds), 1) - assert.isBelow(Math.abs(invalidPasswordTime.milliseconds - invalidEmailTime.milliseconds), 10) - }) -}) diff --git a/tests/guards/basic_auth/authenticate.spec.ts b/tests/guards/basic_auth/authenticate.spec.ts deleted file mode 100644 index cf525ee..0000000 --- a/tests/guards/basic_auth/authenticate.spec.ts +++ /dev/null @@ -1,256 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { pEvent, createTables, createDatabase, createEmitter } from '../../helpers.js' -import { BasicAuthGuardFactory } from '../../../factories/guards/basic_auth/main.js' - -test.group('BasicAuth guard | authenticate', () => { - test('authenticate user using credentials', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - - ctx.request.request.headers.authorization = `Basic ${Buffer.from( - `${user.email}:secret` - ).toString('base64')}` - - const [authSucceeded] = await Promise.all([ - pEvent(emitter, 'basic_auth:authentication_succeeded'), - basicAuthGuard.authenticate(), - ]) - - expectTypeOf(basicAuthGuard.authenticate).returns.toMatchTypeOf>() - assert.equal(authSucceeded!.user.id, user.id) - assert.equal(authSucceeded!.user.id, basicAuthGuard.getUserOrFail().id) - assert.equal(basicAuthGuard.getUserOrFail().id, user.id) - assert.isTrue(basicAuthGuard.isAuthenticated) - assert.isTrue(basicAuthGuard.authenticationAttempted) - }) - - test('throw error when credentials are missing', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - - const [authFailed, authentication] = await Promise.allSettled([ - pEvent(emitter, 'basic_auth:authentication_failed'), - basicAuthGuard.authenticate(), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authentication.status, 'rejected') - - if (authFailed.status === 'fulfilled') { - assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') - } - if (authentication.status === 'rejected') { - assert.equal(authentication.reason.message, 'Invalid basic auth credentials') - } - - assert.isTrue(basicAuthGuard.authenticationAttempted) - assert.isFalse(basicAuthGuard.isAuthenticated) - assert.isUndefined(basicAuthGuard.user) - }) - - test('throw error when user does not exists', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - - ctx.request.request.headers.authorization = `Basic ${Buffer.from(`foo:secret`).toString( - 'base64' - )}` - - const [authFailed, authentication] = await Promise.allSettled([ - pEvent(emitter, 'basic_auth:authentication_failed'), - basicAuthGuard.authenticate(), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authentication.status, 'rejected') - - if (authFailed.status === 'fulfilled') { - assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') - } - if (authentication.status === 'rejected') { - assert.equal(authentication.reason.message, 'Invalid basic auth credentials') - } - - assert.isTrue(basicAuthGuard.authenticationAttempted) - assert.isFalse(basicAuthGuard.isAuthenticated) - assert.isUndefined(basicAuthGuard.user) - }) - - test('throw error when password is invalid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - - ctx.request.request.headers.authorization = `Basic ${Buffer.from( - `${user.email}:wrongpassword` - ).toString('base64')}` - - const [authFailed, authentication] = await Promise.allSettled([ - pEvent(emitter, 'basic_auth:authentication_failed'), - basicAuthGuard.authenticate(), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authentication.status, 'rejected') - - if (authFailed.status === 'fulfilled') { - assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') - } - if (authentication.status === 'rejected') { - assert.equal(authentication.reason.message, 'Invalid basic auth credentials') - } - - assert.isTrue(basicAuthGuard.authenticationAttempted) - assert.isFalse(basicAuthGuard.isAuthenticated) - assert.isUndefined(basicAuthGuard.user) - }) -}) - -test.group('BasicAuth guard | getUserOrFail', () => { - test('throw error when using getUserOrFail and user is authenticated', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - - const [authFailed, authentication] = await Promise.allSettled([ - pEvent(emitter, 'basic_auth:authentication_failed'), - basicAuthGuard.authenticate(), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authentication.status, 'rejected') - - if (authFailed.status === 'fulfilled') { - assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') - } - if (authentication.status === 'rejected') { - assert.equal(authentication.reason.message, 'Invalid basic auth credentials') - } - - assert.isTrue(basicAuthGuard.authenticationAttempted) - assert.isFalse(basicAuthGuard.isAuthenticated) - assert.throws(() => basicAuthGuard.getUserOrFail(), 'Invalid basic auth credentials') - }) -}) - -test.group('BasicAuth guard | check', () => { - test('check if user is logged in using check method', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - - ctx.request.request.headers.authorization = `Basic ${Buffer.from( - `${user.email}:secret` - ).toString('base64')}` - - const [authSucceeded, state] = await Promise.all([ - pEvent(emitter, 'basic_auth:authentication_succeeded'), - basicAuthGuard.check(), - ]) - - assert.isTrue(state) - expectTypeOf(basicAuthGuard.authenticate).returns.toMatchTypeOf>() - assert.equal(authSucceeded!.user.id, user.id) - assert.equal(authSucceeded!.user.id, basicAuthGuard.getUserOrFail().id) - assert.equal(basicAuthGuard.getUserOrFail().id, user.id) - assert.isTrue(basicAuthGuard.isAuthenticated) - assert.isTrue(basicAuthGuard.authenticationAttempted) - }) - - test('throw error when calling authenticate after check and user is not authenticated', async ({ - assert, - }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - - ctx.request.request.headers.authorization = `Basic ${Buffer.from( - `${user.email}:wrongpassword` - ).toString('base64')}` - - const [authFailed, , authentication] = await Promise.allSettled([ - pEvent(emitter, 'basic_auth:authentication_failed'), - basicAuthGuard.check(), - basicAuthGuard.authenticate(), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authentication.status, 'rejected') - - if (authFailed.status === 'fulfilled') { - assert.equal(authFailed.value!.error.message, 'Invalid basic auth credentials') - } - if (authentication.status === 'rejected') { - assert.equal(authentication.reason.message, 'Invalid basic auth credentials') - } - - assert.isTrue(basicAuthGuard.authenticationAttempted) - assert.isFalse(basicAuthGuard.isAuthenticated) - assert.isUndefined(basicAuthGuard.user) - }) -}) - -test.group('BasicAuth guard | authenticateAsClient', () => { - test('throw error when calling authenticateAsClient', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - const ctx = new HttpContextFactory().create() - const basicAuthGuard = new BasicAuthGuardFactory().create(ctx, emitter) - await basicAuthGuard.authenticateAsClient(user) - }).throws('Cannot authenticate as a client when using basic auth') -}) diff --git a/tests/guards/session/attempt.spec.ts b/tests/guards/session/attempt.spec.ts deleted file mode 100644 index 97a2a6a..0000000 --- a/tests/guards/session/attempt.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' -import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' - -test.group('Session guard | attempt', () => { - test('login user using email and password', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [credentialsVerified] = await Promise.all([ - pEvent(emitter, 'session_auth:credentials_verified'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.attempt(user.email, 'secret') - }), - ]) - - assert.strictEqual(credentialsVerified?.user, sessionGuard.user) - assert.equal(credentialsVerified?.uid, sessionGuard.user!.email) - assert.equal(sessionGuard.user!.id, user.id) - - /** - * since the attempt method will fetch user from db, the local - * and refetched instances will be different - */ - assert.notStrictEqual(sessionGuard.user, user) - - assert.isFalse(sessionGuard.isLoggedOut) - assert.isFalse(sessionGuard.isAuthenticated) - assert.isFalse(sessionGuard.authenticationAttempted) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - }) - - test('throw error when password is invalid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [loginFailed, attemptResult] = await Promise.allSettled([ - pEvent(emitter, 'session_auth:login_failed'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.attempt(user.email, 'foo') - }), - ]) - - assert.equal(attemptResult.status, 'rejected') - assert.equal(loginFailed.status, 'fulfilled') - if (attemptResult.status === 'rejected') { - assert.equal(attemptResult.reason.message, 'Invalid credentials') - } - }) - - test('throw error when unable to find the user by uid', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [loginFailed, attemptResult] = await Promise.allSettled([ - pEvent(emitter, 'session_auth:login_failed'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.attempt('foo', 'foo') - }), - ]) - - assert.equal(attemptResult.status, 'rejected') - assert.equal(loginFailed.status, 'fulfilled') - if (attemptResult.status === 'rejected') { - assert.equal(attemptResult.reason.message, 'Invalid credentials') - } - }) -}) diff --git a/tests/guards/session/authenticate.spec.ts b/tests/guards/session/authenticate.spec.ts deleted file mode 100644 index a1124f9..0000000 --- a/tests/guards/session/authenticate.spec.ts +++ /dev/null @@ -1,480 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' -import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' -import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' -import { - pEvent, - timeTravel, - parseCookies, - createTables, - defineCookies, - createDatabase, - createEmitter, -} from '../../helpers.js' - -test.group('Session guard | authenticate | session id', () => { - test('authenticate existing session for auth', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [authSucceeded] = await Promise.all([ - pEvent(emitter, 'session_auth:authentication_succeeded'), - sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - await sessionGuard.authenticate() - expectTypeOf(sessionGuard.authenticate).returns.toMatchTypeOf>() - }), - ]) - - assert.equal(authSucceeded!.sessionId, ctx.session.sessionId) - assert.equal(authSucceeded!.user.id, user.id) - assert.isUndefined(authSucceeded!.rememberMeToken) - assert.equal(sessionGuard.getUserOrFail().id, user.id) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isTrue(sessionGuard.isAuthenticated) - assert.isTrue(sessionGuard.authenticationAttempted) - assert.isFalse(sessionGuard.viaRemember) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - }) - - test('throw error when session does not have user id', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [authFailed, authenticateCall] = await Promise.allSettled([ - pEvent(emitter, 'session_auth:authentication_failed'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authenticateCall.status, 'rejected') - assert.equal( - ('reason' in authenticateCall && authenticateCall.reason).message, - 'Invalid or expired authentication session' - ) - }) - - test('throw error when session has id but user has been deleted', async () => { - const db = await createDatabase() - await createTables(db) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await user.delete() - - await sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - await sessionGuard.authenticate() - }) - }).throws('Invalid or expired authentication session') -}) - -test.group('Session guard | authenticate | remember me token', () => { - test('create session when authentication is sucessful via remember me tokens', async ({ - assert, - }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - - const token = RememberMeToken.create(user.id, '1 year', 'web') - await tokensProvider.createToken(token) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - const [authSucceeded] = await Promise.all([ - pEvent(emitter, 'session_auth:authentication_succeeded'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }), - ]) - - assert.equal(authSucceeded!.sessionId, ctx.session.sessionId) - assert.equal(authSucceeded!.user.id, user.id) - assert.exists(authSucceeded!.rememberMeToken) - assert.equal(sessionGuard.getUserOrFail().id, user.id) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isTrue(sessionGuard.isAuthenticated) - assert.isTrue(sessionGuard.authenticationAttempted) - assert.isTrue(sessionGuard.viaRemember) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - - /** - * Since the token was generated within 1 minute of using - * it. We do not refresh it inside the db - */ - const freshToken = await tokensProvider.getTokenBySeries(token.series) - assert.equal(freshToken!.hash, token.hash) - - const parsedCookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) - assert.equal(parsedCookies.remember_web.value, token.value!.release()) - }) - - test('recycle remember me token when using it after 1 min of last update', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const token = RememberMeToken.create(user.id, '1 year', 'web') - await tokensProvider.createToken(token) - - /** - * Travel 1 minute in future - */ - timeTravel(60) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }) - - assert.equal(sessionGuard.getUserOrFail().id, user.id) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isTrue(sessionGuard.isAuthenticated) - assert.isTrue(sessionGuard.authenticationAttempted) - assert.isTrue(sessionGuard.viaRemember) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - - const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) - const decodedToken = RememberMeToken.decode(cookies.remember_web.value)! - - /** - * Since the token was generated within 1 minute of using - * it. We do not refresh it inside the db - */ - const freshToken = await tokensProvider.getTokenBySeries(decodedToken.series) - assert.notEqual(freshToken!.hash, token.hash) - assert.equal(freshToken!.series, token.series) - - const parsedCookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) - assert.notEqual(parsedCookies.remember_web.value, token.value) - }) - - test('throw error when remember me token is invalid', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: 'foobar', - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }) - }).throws('Invalid or expired authentication session') - - test('throw error when remember me token has been expired', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const token = RememberMeToken.create(user.id, '1 minute', 'web') - await tokensProvider.createToken(token) - - /** - * Travel 2 minute in future - */ - timeTravel(120) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }) - }).throws('Invalid or expired authentication session') - - test('throw error when remember me token does not exist', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const token = RememberMeToken.create(user.id, '1 year', 'web') - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }) - }).throws('Invalid or expired authentication session') - - test('throw error when user has been deleted', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const token = RememberMeToken.create(user.id, '1 year', 'web') - await tokensProvider.createToken(token) - - await user.delete() - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }) - }).throws('Invalid or expired authentication session') - - test('throw error when remember me token type does not match', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const token = RememberMeToken.create(user.id, '1 year', 'web') - await tokensProvider.createToken(token) - - /** - * A matching token generated for different purpose should - * fail. - */ - await db.from('remember_me_tokens').where('series', token.series).update({ type: 'foo_token' }) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }) - }).throws('Invalid or expired authentication session') - - test('multiple calls to authenticate should result in noop', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - await sessionGuard.authenticate() - await user.delete() - const authUser = await sessionGuard.authenticate() - assert.equal(authUser.id, user.id) - }) - }) -}) - -test.group('Session guard | check', () => { - test('return logged-in user when check method called', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [authFailed, authenticateCall] = await Promise.allSettled([ - pEvent(emitter, 'session_auth:authentication_failed'), - sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - return sessionGuard.check() - }), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authenticateCall.status, 'fulfilled') - if (authenticateCall.status === 'fulfilled') { - assert.isTrue(authenticateCall.value) - } - }) - - test('do not throw error when auth.check is used with non-logged in user', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [authFailed, authenticateCall] = await Promise.allSettled([ - pEvent(emitter, 'session_auth:authentication_failed'), - sessionMiddleware.handle(ctx, async () => { - return sessionGuard.check() - }), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authenticateCall.status, 'fulfilled') - if (authenticateCall.status === 'fulfilled') { - assert.isFalse(authenticateCall.value) - } - }) - - test('throw error when calling authenticate after check', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [authFailed, authenticateCall] = await Promise.allSettled([ - pEvent(emitter, 'session_auth:authentication_failed'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.check() - await sessionGuard.authenticate() - }), - ]) - - assert.equal(authFailed.status, 'fulfilled') - assert.equal(authenticateCall.status, 'rejected') - assert.equal( - ('reason' in authenticateCall && authenticateCall.reason).message, - 'Invalid or expired authentication session' - ) - }) -}) - -test.group('Session guard | authenticateAsClient', () => { - test('get authentication session via authenticateAsClient', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - - assert.deepEqual(await sessionGuard.authenticateAsClient(user), { - session: { - auth_web: user.id, - }, - }) - }) -}) diff --git a/tests/guards/session/define_config.spec.ts b/tests/guards/session/define_config.spec.ts deleted file mode 100644 index 91ea688..0000000 --- a/tests/guards/session/define_config.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { AppFactory } from '@adonisjs/core/factories/app' -import { ApplicationService } from '@adonisjs/core/types' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { HashManagerFactory } from '@adonisjs/core/factories/hash' - -import { providers } from '../../../index.js' -import { createDatabase, createEmitter } from '../../helpers.js' -import { SessionGuard } from '../../../src/guards/session/guard.js' -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { LucidUserProvider } from '../../../src/auth/user_providers/main.js' -import { sessionGuard, tokensProvider } from '../../../src/guards/session/define_config.js' - -const BASE_URL = new URL('./', import.meta.url) -const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService -await app.init() - -test.group('sessionGuard', () => { - test('configure session guard', async ({ assert, expectTypeOf }) => { - const sessionGuardProvider = sessionGuard({ - provider: providers.lucid({ - model: async () => { - return { - default: FactoryUser, - } - }, - passwordColumnName: 'password', - uids: ['email'], - }), - }) - - app.container.bind('emitter', () => createEmitter() as any) - app.container.bind('hash', () => new HashManagerFactory().create()) - - const sessionFactory = await sessionGuardProvider.resolver('web', app) - assert.isFunction(sessionFactory) - expectTypeOf(sessionFactory).returns.toMatchTypeOf< - SessionGuard> - >() - - const ctx = new HttpContextFactory().create() - assert.instanceOf(sessionFactory(ctx), SessionGuard) - }) - - test('throw error when no provider is provided', async () => { - await sessionGuard({} as any).resolver('web', app) - }).throws('Invalid user provider defined on "web" guard') - - test('configure session guard with tokens provider', async ({ assert, expectTypeOf }) => { - const sessionGuardProvider = sessionGuard({ - provider: providers.lucid({ - model: async () => { - return { - default: FactoryUser, - } - }, - passwordColumnName: 'password', - uids: ['email'], - }), - tokens: tokensProvider.db({ - table: 'remember_me_tokens', - }), - }) - - app.container.bind('emitter', () => createEmitter() as any) - app.container.bind('lucid.db', () => createDatabase()) - app.container.bind('hash', () => new HashManagerFactory().create()) - - const sessionFactory = await sessionGuardProvider.resolver('web', app) - assert.isFunction(sessionFactory) - expectTypeOf(sessionFactory).returns.toMatchTypeOf< - SessionGuard> - >() - - const ctx = new HttpContextFactory().create() - assert.instanceOf(sessionFactory(ctx), SessionGuard) - }) -}) diff --git a/tests/guards/session/get_user.spec.ts b/tests/guards/session/get_user.spec.ts deleted file mode 100644 index c6bdeb0..0000000 --- a/tests/guards/session/get_user.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { createTables, createDatabase, createEmitter } from '../../helpers.js' -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' - -test.group('Session guard | getUser', () => { - test('get user when authentication succeeded', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - ctx.session.put('auth_web', user.id) - await sessionGuard.authenticate() - }) - - assert.equal(sessionGuard.getUserOrFail().id, user.id) - expectTypeOf(sessionGuard.getUserOrFail()).toMatchTypeOf() - }) - - test('throw error when authentication failed and getUser is called', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await assert.rejects(async () => { - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.authenticate() - }) - }) - - assert.throws(() => sessionGuard.getUserOrFail(), 'Invalid or expired authentication session') - }) -}) diff --git a/tests/guards/session/login.spec.ts b/tests/guards/session/login.spec.ts deleted file mode 100644 index 2ad2706..0000000 --- a/tests/guards/session/login.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' -import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' -import { createDatabase, createEmitter, createTables, pEvent, parseCookies } from '../../helpers.js' - -test.group('Session guard | login', () => { - test('login a user using the user object', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.login(user) - }) - - assert.strictEqual(sessionGuard.user, user) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isFalse(sessionGuard.isAuthenticated) - assert.isFalse(sessionGuard.authenticationAttempted) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - }) - - test('emit events around user login', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [loginAttempted, loginSucceeded] = await Promise.all([ - pEvent(emitter, 'session_auth:login_attempted'), - pEvent(emitter, 'session_auth:login_succeeded'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.login(user) - }), - ]) - - assert.strictEqual(loginAttempted!.user, user) - assert.strictEqual(loginSucceeded!.user, user) - assert.equal(loginSucceeded!.sessionId, ctx.session.sessionId) - assert.isUndefined(loginSucceeded!.rememberMeToken) - }) - - test('create remember me cookie', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.login(user, true) - }) - - assert.strictEqual(sessionGuard.user, user) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isFalse(sessionGuard.isAuthenticated) - assert.isFalse(sessionGuard.authenticationAttempted) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - - /** - * Parsing response cookies - */ - const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) - assert.property(cookies, 'remember_web') - assert.equal(cookies.remember_web.maxAge, 157788000) - assert.equal(cookies.remember_web.httpOnly, true) - - /** - * Ensure the remember me cookie can be decoded by - * the server - */ - const decodedToken = RememberMeToken.decode(cookies.remember_web.value)! - assert.properties(decodedToken, ['series', 'value']) - - /** - * Verifying the cookie exists in the database - */ - const persistedToken = await tokensProvider.getTokenBySeries(decodedToken.series) - assert.exists(persistedToken) - assert.isTrue(persistedToken!.verify(decodedToken.value)) - }) - - test('throw error when trying to create remember_me token with tokens provider', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.login(user, true) - }) - }).throws( - 'Cannot use "rememberMe" feature. Please configure the tokens provider inside config/auth file' - ) - - test('throw error when trying to use session guard without session middleware', async () => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - - await sessionGuard.login(user) - }).throws( - 'Cannot login user. Make sure you have installed the "@adonisjs/session" package and configured its middleware' - ) -}) diff --git a/tests/guards/session/login_via_id.spec.ts b/tests/guards/session/login_via_id.spec.ts deleted file mode 100644 index 5452eec..0000000 --- a/tests/guards/session/login_via_id.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' -import { createDatabase, createEmitter, createTables, pEvent } from '../../helpers.js' - -test.group('Session guard | loginViaId', () => { - test('login user via id', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults({ - password: await new Scrypt({}).make('secret'), - }) - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await Promise.all([ - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.loginViaId(user.id) - }), - ]) - - assert.equal(sessionGuard.user!.id, user.id) - // since the attempt method will fetch from db - assert.notStrictEqual(sessionGuard.user, user) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isFalse(sessionGuard.isAuthenticated) - assert.isFalse(sessionGuard.authenticationAttempted) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - }) - - test('throw error when user for the id does not exists', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const [loginFailed, attemptResult] = await Promise.allSettled([ - pEvent(emitter, 'session_auth:login_failed'), - sessionMiddleware.handle(ctx, async () => { - await sessionGuard.loginViaId(1) - }), - ]) - - assert.equal(attemptResult.status, 'rejected') - assert.equal(loginFailed.status, 'fulfilled') - if (attemptResult.status === 'rejected') { - assert.equal(attemptResult.reason.message, 'Invalid credentials') - } - }) -}) diff --git a/tests/guards/session/logout.spec.ts b/tests/guards/session/logout.spec.ts deleted file mode 100644 index f2e0a15..0000000 --- a/tests/guards/session/logout.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { HttpContextFactory } from '@adonisjs/core/factories/http' - -import { FactoryUser } from '../../../factories/core/lucid_user_provider.js' -import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' -import { SessionGuardFactory } from '../../../factories/guards/session/guard_factory.js' -import { - pEvent, - createTables, - parseCookies, - createEmitter, - createDatabase, - defineCookies, -} from '../../helpers.js' -import { DatabaseRememberTokenProvider } from '../../../src/guards/session/token_providers/database.js' - -test.group('Session guard | logout', () => { - test('logout user by deleting auth data from session store', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.login(user) - }) - - assert.strictEqual(sessionGuard.user, user) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isFalse(sessionGuard.isAuthenticated) - assert.isFalse(sessionGuard.authenticationAttempted) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - - /** - * Logging out - */ - await sessionGuard.logout() - assert.deepEqual(ctx.session.all(), {}) - }) - - test('logout user by deleting remember me token and cookie', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const user = await FactoryUser.createWithDefaults() - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const token = RememberMeToken.create(user.id, '1 year', 'web') - await tokensProvider.createToken(token) - - const ctx = new HttpContextFactory().create() - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.logout() - }) - - assert.deepEqual(ctx.session.all(), {}) - - const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) - assert.property(cookies, 'remember_web') - assert.equal(cookies.remember_web.maxAge, -1) - assert.equal(cookies.remember_web.httpOnly, true) - - const persistedToken = await tokensProvider.getTokenBySeries(token.series) - assert.isNull(persistedToken) - }) - - test('emit logged_out event', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const ctx = new HttpContextFactory().create() - const user = await FactoryUser.createWithDefaults() - const sessionGuard = new SessionGuardFactory().create(ctx, emitter) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.login(user) - }) - - assert.strictEqual(sessionGuard.user, user) - assert.isFalse(sessionGuard.isLoggedOut) - assert.isFalse(sessionGuard.isAuthenticated) - assert.isFalse(sessionGuard.authenticationAttempted) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - - /** - * Logging out - */ - - const [loggedOut] = await Promise.all([ - pEvent(emitter, 'session_auth:logged_out'), - sessionGuard.logout(), - ]) - - assert.deepEqual(loggedOut!.user, sessionGuard.user) - assert.equal(loggedOut!.sessionId, ctx.session.sessionId) - assert.deepEqual(ctx.session.all(), {}) - }) - - test('silently ignore invalid remember me token', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const emitter = createEmitter() - const tokensProvider = new DatabaseRememberTokenProvider(db, { table: 'remember_me_tokens' }) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const ctx = new HttpContextFactory().create() - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: 'foo', - type: 'encrypted', - }, - ]) - - const sessionGuard = new SessionGuardFactory() - .create(ctx, emitter) - .withRememberMeTokens(tokensProvider) - await sessionMiddleware.handle(ctx, async () => { - await sessionGuard.logout() - }) - - assert.deepEqual(ctx.session.all(), {}) - - const cookies = parseCookies(ctx.response.getHeader('set-cookie') as string[]) - assert.property(cookies, 'remember_web') - assert.equal(cookies.remember_web.maxAge, -1) - assert.equal(cookies.remember_web.httpOnly, true) - }) -}) diff --git a/tests/guards/session/remember_me_db_provider.spec.ts b/tests/guards/session/remember_me_db_provider.spec.ts deleted file mode 100644 index e65ea94..0000000 --- a/tests/guards/session/remember_me_db_provider.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' - -import { createDatabase, createTables, timeTravel } from '../../helpers.js' -import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' -import { DatabaseRememberTokenFactory } from '../../../factories/guards/session/database_remember_token_factory.js' - -test.group('Remember me token provider', () => { - test('persist remember me token to the database', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = RememberMeToken.create(1, '20mins', 'web') - const provider = new DatabaseRememberTokenFactory().create(db) - - await provider.createToken(token) - const tokens = await db.from('remember_me_tokens') - - assert.lengthOf(tokens, 1) - assert.equal(tokens[0].user_id, 1) - assert.equal(tokens[0].series, token.series) - assert.equal(tokens[0].token, token.hash) - assert.equal(tokens[0].guard, 'web') - assert.equal(tokens[0].type, 'remember_me_token') - assert.isDefined(tokens[0].created_at) - assert.isDefined(tokens[0].updated_at) - assert.equal(new Date(tokens[0].expires_at).getTime(), token.expiresAt.getTime()) - }) - - test('get token by series', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = RememberMeToken.create(1, '20mins', 'web') - const provider = new DatabaseRememberTokenFactory().create(db) - - await provider.createToken(token) - const freshToken = (await provider.getTokenBySeries(token.series))! - - assert.instanceOf(freshToken, RememberMeToken) - assert.equal(freshToken.series, token.series) - assert.isUndefined(freshToken.value) - assert.equal(freshToken.hash, token.hash) - assert.equal(freshToken.guard, 'web') - assert.equal(freshToken.type, 'remember_me_token') - assert.equal(freshToken.createdAt.getTime(), token.createdAt.getTime()) - assert.equal(freshToken.updatedAt.getTime(), token.updatedAt.getTime()) - assert.equal(freshToken.expiresAt.getTime(), token.expiresAt.getTime()) - }) - - test('return null when token has been expired', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = RememberMeToken.create(1, '20mins', 'web') - const provider = new DatabaseRememberTokenFactory().create(db) - - await provider.createToken(token) - timeTravel(21 * 60) // travel by 21 mins - - const freshToken = await provider.getTokenBySeries(token.series) - assert.isNull(freshToken) - }) - - test('return null when token type mismatches', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const token = RememberMeToken.create(1, '20mins', 'web') - const provider = new DatabaseRememberTokenFactory().create(db) - - await provider.createToken(token) - - await db.from('remember_me_tokens').where('series', token.series).update({ type: 'foo' }) - const freshToken = await provider.getTokenBySeries(token.series) - assert.isNull(freshToken) - }) -}) diff --git a/tests/guards/session/remember_me_token.spec.ts b/tests/guards/session/remember_me_token.spec.ts deleted file mode 100644 index a5a7fef..0000000 --- a/tests/guards/session/remember_me_token.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { createHash } from 'node:crypto' -import { Secret, base64 } from '@adonisjs/core/helpers' - -import { freezeTime } from '../../helpers.js' -import { RememberMeToken } from '../../../src/guards/session/remember_me_token.js' - -test.group('Remember me token', () => { - test('create a remember me token', ({ assert }) => { - freezeTime() - const date = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(date.getSeconds() + 60 * 20) - - const token = RememberMeToken.create(1, '20mins', 'web') - assert.equal(token.userId, 1) - assert.equal(token.createdAt.getTime(), date.getTime()) - assert.equal(token.updatedAt.getTime(), date.getTime()) - assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) - assert.lengthOf(token.series, 15) - assert.instanceOf(token.value, Secret) - assert.equal(token.guard, 'web') - assert.equal(token.type, 'remember_me_token') - assert.equal( - token.hash, - createHash('sha256') - .update(base64.urlDecode(token.value!.release().split('.')[1])!) - .digest('hex') - ) - }) - - test('create a remember me token from persisted data', ({ assert }) => { - const token = RememberMeToken.createFromPersisted(1, 'web', '1234') - assert.equal(token.series, '1234') - assert.equal(token.userId, 1) - assert.equal(token.guard, 'web') - assert.equal(token.type, 'remember_me_token') - assert.isUndefined(token.createdAt) - assert.isUndefined(token.updatedAt) - assert.isUndefined(token.expiresAt) - assert.isUndefined(token.value) - assert.isUndefined(token.hash) - }) - - test('refresh remember me token', ({ assert }) => { - freezeTime() - const date = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(date.getSeconds() + 60 * 20) - - const token = RememberMeToken.createFromPersisted(1, 'web', '1234') - token.refresh('20mins') - - /** - * Still undefined because refresh method does not update - * createdAt timestamp. The token providers should do - * that - */ - assert.isUndefined(token.createdAt) - - assert.equal(token.userId, 1) - assert.equal(token.updatedAt.getTime(), date.getTime()) - assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) - assert.equal(token.series, '1234') - assert.instanceOf(token.value, Secret) - assert.equal(token.guard, 'web') - assert.equal(token.type, 'remember_me_token') - assert.equal( - token.hash, - createHash('sha256') - .update(base64.urlDecode(token.value!.release().split('.')[1])!) - .digest('hex') - ) - }) - - test('verify token hash', ({ assert }) => { - freezeTime() - const date = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(date.getSeconds() + 60 * 20) - - const token = RememberMeToken.createFromPersisted(1, 'web', '1234') - token.refresh('20mins') - assert.isTrue(token.verify(base64.urlDecode(token.value!.release().split('.')[1])!)) - }) - - test('decode remember me token', ({ assert }) => { - const token = RememberMeToken.create(1, '20mins', 'web') - const { series, value } = RememberMeToken.decode(token.value!.release())! - - assert.equal(series, token.series) - assert.isTrue(token.verify(value)) - }) - - test('fail to decode invalid values', ({ assert }) => { - assert.isNull(RememberMeToken.decode(null as any)) - assert.isNull(RememberMeToken.decode('')) - assert.isNull(RememberMeToken.decode('...')) - assert.isNull(RememberMeToken.decode('foobar')) - assert.isNull(RememberMeToken.decode('foo.bar')) - }) -}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 09fcc9d..3c22939 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -24,7 +24,7 @@ import { LoggerFactory } from '@adonisjs/core/factories/logger' import { EncryptionFactory } from '@adonisjs/core/factories/encryption' import { SessionGuardEvents } from '../src/guards/session/types.js' -import { FactoryUser } from '../factories/core/lucid_user_provider.js' +import { FactoryUser } from '../backup/factories/core/lucid_user_provider.js' import { BasicAuthGuardEvents } from '../src/guards/basic_auth/types.js' export const encryption: Encryption = new EncryptionFactory().create() From 42ce34c9102659390a93ef0e8f99ca9a3ab310e9 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 12 Jan 2024 11:40:16 +0530 Subject: [PATCH 62/96] refactor: simplify session guard --- bin/test.ts | 8 +- factories/auth/main.ts | 12 + factories/session_guard/main.ts | 123 +++++ modules/session_guard/debug.ts | 12 + modules/session_guard/guard.ts | 430 +++++++++++++++ modules/session_guard/remember_me_token.ts | 202 +++++++ modules/session_guard/types.ts | 178 ++++++ package.json | 2 +- .../session_guard/guard_authenticate.spec.ts | 521 ++++++++++++++++++ tests/session_guard/remember_me_token.spec.ts | 122 ++++ 10 files changed, 1603 insertions(+), 7 deletions(-) create mode 100644 factories/session_guard/main.ts create mode 100644 modules/session_guard/debug.ts create mode 100644 modules/session_guard/guard.ts create mode 100644 modules/session_guard/remember_me_token.ts create mode 100644 modules/session_guard/types.ts create mode 100644 tests/session_guard/guard_authenticate.spec.ts create mode 100644 tests/session_guard/remember_me_token.spec.ts diff --git a/bin/test.ts b/bin/test.ts index dbe2451..962247d 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -9,20 +9,16 @@ configure({ suites: [ { name: 'session', - files: ['tests/guards/session/**/*.spec.ts'], + files: ['tests/session_guard/**/*.spec.ts'], }, { name: 'basic_auth', - files: ['tests/guards/basic_auth/**/*.spec.ts'], + files: ['tests/basic_auth_guard/**/*.spec.ts'], }, { name: 'auth', files: ['tests/auth/**/*.spec.ts'], }, - { - name: 'core', - files: ['tests/core/**/*.spec.ts'], - }, ], plugins: [assert(), fileSystem(), expectTypeOf(), snapshot()], }) diff --git a/factories/auth/main.ts b/factories/auth/main.ts index d749189..9f48652 100644 --- a/factories/auth/main.ts +++ b/factories/auth/main.ts @@ -11,9 +11,21 @@ import { GUARD_KNOWN_EVENTS } from '../../src/symbols.js' import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' import { AuthClientResponse, GuardContract } from '../../src/types.js' +/** + * @note + * Should not be exported to the outside world + */ export type FakeUser = { id: number } + +/** + * Fake guard is an implementation of the auth guard contract + * that uses in-memory values used for testing. + * + * @note + * Should not be exported to the outside world + */ export class FakeGuard implements GuardContract { isAuthenticated: boolean = false authenticationAttempted: boolean = false diff --git a/factories/session_guard/main.ts b/factories/session_guard/main.ts new file mode 100644 index 0000000..76a7d63 --- /dev/null +++ b/factories/session_guard/main.ts @@ -0,0 +1,123 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' +import { GuardUser, SessionUserProviderContract } from '../../modules/session_guard/types.js' +import { PROVIDER_REAL_USER } from '../../src/symbols.js' + +/** + * Representation of a fake user used to test + * the session guard. + * + * @note + * Should not be exported to the outside world + */ +type SessionFakeUser = { + id: number + email: string + password: string +} + +/** + * Collection of dummy users + */ +const users: SessionFakeUser[] = [ + { + id: 1, + email: 'virk@adonisjs.com', + password: 'secret', + }, + { + id: 2, + email: 'romain@adonisjs.com', + password: 'secret', + }, +] + +/** + * Implementation of a user provider to be used by session guard for + * authentication. Used for testing. + * + * @note + * Should not be exported to the outside world + */ +export class SessionFakeUserProvider implements SessionUserProviderContract { + declare [PROVIDER_REAL_USER]: SessionFakeUser + #token?: RememberMeToken + + /** + * Provide a token to use for "findRememberMeTokenBySeries" method. + */ + useToken(token: RememberMeToken) { + this.#token = token + } + + async createUserForGuard(user: SessionFakeUser): Promise> { + return { + getId() { + return user.id + }, + getOriginal() { + return user + }, + } + } + + async findById(userId: string | number | BigInt): Promise | null> { + const user = users.find(({ id }) => id === userId) + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + async findByUid(uid: string | number): Promise | null> { + const user = users.find(({ email }) => email === uid) + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + async verifyCredentials( + uid: string | number, + password: string + ): Promise | null> { + const user = await this.findByUid(uid) + if (!user) { + return null + } + + if (user.getOriginal().password !== password) { + return null + } + + return user + } + + async findRememberMeTokenBySeries(series: string): Promise { + if (!this.#token) { + return null + } + if (this.#token.series !== series) { + return null + } + if (this.#token.isExpired() || this.#token.type !== 'remember_me_token') { + return null + } + + return this.#token + } + + async recycleRememberMeToken(token: RememberMeToken): Promise { + this.#token = token + } +} diff --git a/modules/session_guard/debug.ts b/modules/session_guard/debug.ts new file mode 100644 index 0000000..451511d --- /dev/null +++ b/modules/session_guard/debug.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { debuglog } from 'node:util' + +export default debuglog('adonisjs:auth:session_guard') diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts new file mode 100644 index 0000000..4baa8a4 --- /dev/null +++ b/modules/session_guard/guard.ts @@ -0,0 +1,430 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import { RuntimeException } from '@adonisjs/core/exceptions' +import type { EmitterLike } from '@adonisjs/core/types/events' + +import debug from './debug.js' +import { RememberMeToken } from './remember_me_token.js' +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import type { AuthClientResponse, GuardContract } from '../../src/types.js' +import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' +import type { + SessionGuardConfig, + SessionGuardEvents, + SessionUserProviderContract, +} from './types.js' + +/** + * Session guard is an implementation of the AuthGuard contract to authenticate + * incoming HTTP requests using sessions. + * + * It also goes beyond to create login sessions for users and verify their + * credentials. + */ +export class SessionGuard> + implements GuardContract +{ + /** + * Events emitted by the guard + */ + declare [GUARD_KNOWN_EVENTS]: SessionGuardEvents + + /** + * A unique name for the guard. It is used for prefixing + * session data and remember me cookies + */ + #name: string + + /** + * Reference to the current HTTP context + */ + #ctx: HttpContext + + /** + * Configuration + */ + #config: SessionGuardConfig + + /** + * Provider to lookup user details + */ + #userProvider: UserProvider + + /** + * Emitter to emit events + */ + #emitter: EmitterLike> + + /** + * Driver name of the guard + */ + driverName: 'session' = 'session' + + /** + * Whether or not the authentication has been attempted + * during the current request. + */ + authenticationAttempted = false + + /** + * Find if the user has been logged out during + * the current request + */ + isLoggedOut = false + + /** + * A boolean to know if the current request has + * been authenticated + */ + isAuthenticated = false + + /** + * A boolean to know if the current request is authenticated + * using the "rememember_me" token. + */ + viaRemember = false + + /** + * Reference to an instance of the authenticated or logged-in + * user. The value only exists after calling one of the + * following methods. + * + * - login + * - loginViaId + * - attempt + * - authenticate + * - check + * + * You can use the "getUserOrFail" method to throw an exception if + * the request is not authenticated. + */ + user?: UserProvider[typeof PROVIDER_REAL_USER] + + /** + * The key used to store the logged-in user id inside + * session + */ + get sessionKeyName() { + return `auth_${this.#name}` + } + + /** + * The key used to store the remember me token cookie + */ + get rememberMeKeyName() { + return `remember_${this.#name}` + } + + constructor( + name: string, + config: SessionGuardConfig, + ctx: HttpContext, + emitter: EmitterLike>, + userProvider: UserProvider + ) { + this.#name = name + this.#ctx = ctx + this.#config = config + this.#emitter = emitter + this.#userProvider = userProvider + debug('instantiating "%s" guard, config %O', this.#name, this.#config) + } + + /** + * Returns the session instance for the given request, + * ensuring the property exists + */ + #getSession() { + if (!('session' in this.#ctx)) { + throw new RuntimeException( + 'Cannot authenticate user. Install and configure "@adonisjs/session" package' + ) + } + + return this.#ctx.session + } + + /** + * Emits authentication failure and returns an exception + * to end the authentication cycle. + */ + #authenticationFailed(sessionId: string) { + const error = new E_UNAUTHORIZED_ACCESS('Invalid or expired user session', { + guardDriverName: this.driverName, + }) + + this.#emitter.emit('session_auth:authentication_failed', { + ctx: this.#ctx, + guardName: this.#name, + error, + sessionId, + }) + + return error + } + + /** + * Emits the authentication succeeded event and updates + * the local state to reflect successful authentication + */ + #authenticationSucceeded(sessionId: string, rememberMeToken?: RememberMeToken) { + this.isAuthenticated = true + this.isLoggedOut = false + this.viaRemember = !!rememberMeToken + + this.#emitter.emit('session_auth:authentication_succeeded', { + ctx: this.#ctx, + guardName: this.#name, + sessionId: sessionId, + user: this.user, + rememberMeToken, + }) + } + + /** + * Creates session for a given user by their user id. + */ + #createSessionForUser(userId: string | number | BigInt) { + const session = this.#getSession() + session.put(this.sessionKeyName, userId) + session.regenerate() + } + + /** + * Creates the remember me cookie + */ + #createRememberMeCookie(value: string) { + this.#ctx.response.encryptedCookie(this.rememberMeKeyName, value, { + maxAge: this.#config.rememberMeTokenAge || '2years', + httpOnly: true, + }) + } + + /** + * Recycles the remember me token by updating its timestamps + * and hash within the database. We ensure to only recycle + * when token is older than 1min from last update. + */ + async #recycleRememberMeToken(token: RememberMeToken, rememberMeCookie: string) { + /** + * Updated at with buffer represents the token's last updated + * at date + a buffer of 60 seconds to avoid race conditions + * where two concurrent requests recycles the token. + */ + const updatedAtWithBuffer = new Date(token.updatedAt) + updatedAtWithBuffer.setSeconds(updatedAtWithBuffer.getSeconds() + 60) + + if (new Date() > updatedAtWithBuffer) { + debug('recycling remember me token') + token.refresh(this.#config.rememberMeTokenAge || '2years') + await this.#userProvider.recycleRememberMeToken!(token) + this.#createRememberMeCookie(token.value!.release()) + } else { + this.#createRememberMeCookie(rememberMeCookie) + } + } + + /** + * Authenticates the user using its id read from the session + * store. + * + * - We check the user exists in the db + * - If not, throw exception. + * - Otherwise, update local state to mark the user as logged-in + */ + async #authenticateViaId(loggedInUserId: string | number | BigInt, sessionId: string) { + debug('authenticating user from session') + + /** + * Check the user exists with the provider + */ + const providerUser = await this.#userProvider.findById(loggedInUserId) + if (!providerUser) { + throw this.#authenticationFailed(sessionId) + } + + debug('marking user with id "%s" as authenticated', providerUser.getId()) + + this.user = providerUser.getOriginal() + this.#authenticationSucceeded(sessionId) + + return this.user + } + + /** + * Authenticates user from the remember me cookie. Creates a fresh + * session for them and recycles the remember me token as well. + */ + async #authenticateViaRememberCookie(rememberMeCookie: string, sessionId: string) { + debug('attempting to authenticate via rememberMeCookie') + + /** + * Fail authentication when user provider does not implement + * APIs needed to verify and recycle remember me tokens + */ + if ( + !this.#userProvider.findRememberMeTokenBySeries || + !this.#userProvider.recycleRememberMeToken + ) { + throw this.#authenticationFailed(sessionId) + } + + /** + * Decode token or fail when unable to do so + */ + const decodedToken = RememberMeToken.decode(rememberMeCookie) + if (!decodedToken) { + throw this.#authenticationFailed(sessionId) + } + + /** + * Search for token via provider and ensure token hash matches the + * token value and guard are the same. + * + * We expect the provider to check for expired tokens, return null for + * expired tokens and optionally delete them. + */ + const token = await this.#userProvider.findRememberMeTokenBySeries(decodedToken.series) + if (!token || !token.verify(decodedToken.value) || token.guard !== this.#name) { + throw this.#authenticationFailed(sessionId) + } + + debug('found valid remember me token') + + /** + * Check if a user for the token exists. Otherwise abort + * authentication + */ + const providerUser = await this.#userProvider.findById(token.userId) + if (!providerUser) { + throw this.#authenticationFailed(sessionId) + } + + /** + * Create session + */ + const userId = providerUser.getId() + debug('marking user with id "%s" as logged in from remember me cookie', userId) + this.#createSessionForUser(userId) + + /** + * Emit event and update local state + */ + debug('marking user with id "%s" as authenticated', userId) + this.user = providerUser.getOriginal() + this.#authenticationSucceeded(sessionId, token) + + await this.#recycleRememberMeToken(token, rememberMeCookie) + return this.user + } + + /** + * Returns an instance of the authenticated user. Or throws + * an exception if the request is not authenticated. + */ + getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { + if (!this.user) { + throw new E_UNAUTHORIZED_ACCESS('Invalid or expired user session', { + guardDriverName: this.driverName, + }) + } + + return this.user + } + + /** + * Authenticates the current HTTP request by reading the userId + * from the session and/or using the remember me token to have + * persistent login. + * + * Calling this method multiple times results in a noop. + */ + async authenticate(): Promise { + /** + * Return early when authentication has already + * been attempted + */ + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + + this.authenticationAttempted = true + const session = this.#getSession() + + /** + * Notify we are starting the authentication process + */ + this.#emitter.emit('session_auth:authentication_attempted', { + ctx: this.#ctx, + guardName: this.#name, + sessionId: session.sessionId, + }) + + /** + * Check if there is a user id inside the session store. + * If yes, fetch the user from the persistent storage + * and mark them as logged-in + */ + const loggedInUserId = session.get(this.sessionKeyName) + if (loggedInUserId) { + return this.#authenticateViaId(loggedInUserId, session.sessionId) + } + + /** + * If rememberMeCookie exists then attempt to authenticate via the + * remember me cookie + */ + const rememberMeCookie = this.#ctx.request.encryptedCookie(this.rememberMeKeyName) + if (rememberMeCookie) { + return this.#authenticateViaRememberCookie(rememberMeCookie, session.sessionId) + } + + /** + * Otherwise fail + */ + throw this.#authenticationFailed(session.sessionId) + } + + /** + * Silently check if the user is authenticated or not, without + * throwing any exceptions + */ + async check(): Promise { + try { + await this.authenticate() + return true + } catch (error) { + if (error instanceof E_UNAUTHORIZED_ACCESS) { + return false + } + + throw error + } + } + + /** + * Returns the session info for the clients to send during + * an HTTP request to mark the user as logged-in. + */ + async authenticateAsClient( + user: UserProvider[typeof PROVIDER_REAL_USER] + ): Promise { + const providerUser = await this.#userProvider.createUserForGuard(user) + const userId = providerUser.getId() + + debug('session_guard: returning client session for user id "%s"', userId) + return { + session: { + [this.sessionKeyName]: userId, + }, + } + } +} diff --git a/modules/session_guard/remember_me_token.ts b/modules/session_guard/remember_me_token.ts new file mode 100644 index 0000000..5aa9d49 --- /dev/null +++ b/modules/session_guard/remember_me_token.ts @@ -0,0 +1,202 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHash } from 'node:crypto' +import string from '@adonisjs/core/helpers/string' +import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' + +/** + * Remember me token represents a remember me token created + * for a peristed login flow. + */ +export class RememberMeToken { + /** + * Decodes a publicly shared token and return the series + * and the token value from it. + * + * Returns null when unable to decode the token because of + * invalid format or encoding. + */ + static decode(value: string): null | { series: string; value: string } { + if (typeof value !== 'string') { + return null + } + + const [series, ...tokenValue] = value.split('.') + if (!series || tokenValue.length === 0) { + return null + } + + const decodedSeries = base64.urlDecode(series) + const decodedValue = base64.urlDecode(tokenValue.join('.')) + if (!decodedSeries || !decodedValue) { + return null + } + + return { + series: decodedSeries, + value: decodedValue, + } + } + + /** + * Creates remember me token instance from persisted information. + */ + static createFromPersisted(attributes: { + series: string + userId: string | number | BigInt + hash: string + guard: string + createdAt: Date + updatedAt: Date + expiresAt: Date + }) { + return new RememberMeToken(attributes) + } + + /** + * Creates a new remember me token instance. Calling this + * method computes the token series, value, hash and + * timestamps + */ + static create( + userId: string | number, + expiry: string | number, + guard: string, + size: number = 30 + ) { + const series = string.random(15) + const seed = string.random(size) + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + string.seconds.parse(expiry)) + + const token = new RememberMeToken({ + series, + userId, + hash: createHash('sha256').update(seed).digest('hex'), + guard, + createdAt, + updatedAt, + expiresAt, + }) + + token.value = new Secret(`${base64.urlEncode(token.series)}.${base64.urlEncode(seed)}`) + return token + } + + /** + * Static name for the token to uniquely identify a + * bucket of tokens + */ + readonly type: 'remember_me_token' = 'remember_me_token' + + /** + * Series is a unique sequence to identify the + * token within database. It should be the + * primary/unique key + */ + series: string + + /** + * Reference to the user id for whom the token + * is generated + */ + userId: string | number | BigInt + + /** + * The series and seed is persisted inside the cookie and later + * splitted to perform the lookup. + */ + value?: Secret + + /** + * Hash is computed from the seed to later verify the validify + * of seed + */ + hash: string + + /** + * Guard for which the token is generated. This is to avoid + * cross guards using each others remember me tokens + */ + guard: string + + /** + * Date/time when the token instance was created + */ + createdAt: Date + + /** + * Date/time when the token was updated + */ + updatedAt: Date + + /** + * Timestamp at which the token will expire + */ + expiresAt: Date + + constructor(attributes: { + series: string + userId: string | number | BigInt + hash: string + guard: string + createdAt: Date + updatedAt: Date + expiresAt: Date + }) { + this.series = attributes.series + this.userId = attributes.userId + this.hash = attributes.hash + this.guard = attributes.guard + this.createdAt = attributes.createdAt + this.updatedAt = attributes.updatedAt + this.expiresAt = attributes.expiresAt + } + + /** + * Refreshes the token's value, hash, updatedAt and + * expiresAt timestamps + */ + refresh(expiry: string | number, size: number = 30) { + const seed = string.random(size) + + /** + * Re-computing public value and hash + */ + this.hash = createHash('sha256').update(seed).digest('hex') + this.value = new Secret(`${base64.urlEncode(this.series)}.${base64.urlEncode(seed)}`) + + /** + * Updating expiry and updated_at timestamp + */ + this.updatedAt = new Date() + this.expiresAt = new Date() + this.expiresAt.setSeconds(this.updatedAt.getSeconds() + string.seconds.parse(expiry)) + } + + /** + * Check if the token has been expired. Verifies + * the "expiresAt" timestamp with the current + * date. + */ + isExpired() { + return this.expiresAt < new Date() + } + + /** + * Verifies the value of a token against the pre-defined hash + */ + verify(value: string): boolean { + const newHash = createHash('sha256').update(value).digest('hex') + return safeEqual(this.hash, newHash) + } +} diff --git a/modules/session_guard/types.ts b/modules/session_guard/types.ts new file mode 100644 index 0000000..5e58ed1 --- /dev/null +++ b/modules/session_guard/types.ts @@ -0,0 +1,178 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import type { Exception } from '@adonisjs/core/exceptions' + +import type { RememberMeToken } from './remember_me_token.js' +import type { PROVIDER_REAL_USER } from '../../src/symbols.js' + +/** + * Guard user is an adapter between the user provider + * and the guard. + * + * The guard is user provider agnostic and therefore it + * needs a adapter to known some basic info about the + * user. + */ +export type GuardUser = { + getId(): string | number | BigInt + getOriginal(): RealUser +} + +/** + * The user provider used by the session guard to lookup + * user and persist remember me tokens + */ +export interface SessionUserProviderContract { + [PROVIDER_REAL_USER]: RealUser + + /** + * Creates a user object that guard can use for authentication + */ + createUserForGuard(user: RealUser): Promise> + + /** + * Find a user by uid. The uid could be one or multiple fields + * to unique identify a user. + * + * This method is called when finding a user for login + */ + findByUid(userId: string | number): Promise | null> + + /** + * Find a user by unique primary id. This method is called when + * authenticating user from their session. + */ + findById(uid: string | number | BigInt): Promise | null> + + /** + * Find a user by uid and verify their password. This method prevents + * timing attacks. + */ + verifyCredentials(uid: string | number, password: string): Promise | null> + + /** + * Persist remember me token. The userId is available via the token.userId property. + */ + createRememberMeToken?(token: RememberMeToken): Promise + + /** + * Delete the remember token by the series value + */ + deleteRememberMeTokenBySeries?(series: string): Promise + + /** + * Find a remember me token by the series value + */ + findRememberMeTokenBySeries?(series: string): Promise + + /** + * Recycle the remember me token attributes. The update must be + * performed by first finding the token by series and then + * updating its attributes. + */ + recycleRememberMeToken?(token: RememberMeToken): Promise +} + +/** + * Config accepted by the session guard + */ +export type SessionGuardConfig = { + rememberMeTokenAge?: number | string +} + +/** + * Events emitted by the session guard + */ +export type SessionGuardEvents = { + /** + * The event is emitted when the user credentials + * have been verified successfully. + */ + 'session_auth:credentials_verified': { + ctx: HttpContext + guardName: string + uid: string + user: User + } + + /** + * The event is emitted when unable to login the + * user. + */ + 'session_auth:login_failed': { + ctx: HttpContext + guardName: string + error: Exception + } + + /** + * The event is emitted when login is attempted for + * a given user. + */ + 'session_auth:login_attempted': { + ctx: HttpContext + guardName: string + user: User + } + + /** + * The event is emitted when user has been logged in + * successfully + */ + 'session_auth:login_succeeded': { + ctx: HttpContext + guardName: string + user: User + sessionId: string + rememberMeToken?: RememberMeToken + } + + /** + * Attempting to authenticate the user + */ + 'session_auth:authentication_attempted': { + ctx: HttpContext + guardName: string + sessionId: string + } + + /** + * Authentication was successful + */ + 'session_auth:authentication_succeeded': { + ctx: HttpContext + guardName: string + user: User + sessionId: string + rememberMeToken?: RememberMeToken + } + + /** + * Authentication failed + */ + 'session_auth:authentication_failed': { + ctx: HttpContext + guardName: string + error: Exception + sessionId: string + } + + /** + * The event is emitted when user has been logged out + * sucessfully + */ + 'session_auth:logged_out': { + ctx: HttpContext + guardName: string + user: User | null + sessionId: string + } +} diff --git a/package.json b/package.json index e2bc7f5..b88f6a5 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "scripts": { "pretest": "npm run lint", "test": "c8 npm run quick:test", - "quick:test": "cross-env NODE_DEBUG=\"adonisjs:auth\" node --enable-source-maps --loader=ts-node/esm ./bin/test.js", + "quick:test": "cross-env NODE_DEBUG=\"adonisjs:auth:*\" node --enable-source-maps --loader=ts-node/esm ./bin/test.js", "clean": "del-cli build", "copy:templates": "copyfiles \"stubs/**/**/*.stub\" build", "compile": "npm run lint && npm run clean && tsc && npm run copy:templates", diff --git a/tests/session_guard/guard_authenticate.spec.ts b/tests/session_guard/guard_authenticate.spec.ts new file mode 100644 index 0000000..a767e33 --- /dev/null +++ b/tests/session_guard/guard_authenticate.spec.ts @@ -0,0 +1,521 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { createEmitter, defineCookies, timeTravel } from '../helpers.js' +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import { SessionGuard } from '../../modules/session_guard/guard.js' +import { SessionFakeUserProvider } from '../../factories/session_guard/main.js' +import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' + +test.group('Session guard | authenticate | via session', () => { + test('mark user as logged-in when a valid session exists', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const user = await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 1) // setting auth state for userId 1 + return guard.authenticate() + }) + + assert.isTrue(guard.authenticationAttempted) + assert.isTrue(guard.isAuthenticated) + assert.isDefined(guard.user) + assert.deepEqual(guard.user, user) + assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + }) + + test('throw error when session does not exist', async ({ assert }) => { + assert.plan(8) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) + assert.equal(error.message, 'Invalid or expired user session') + assert.equal(error.guardDriverName, 'session') + } + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + }) + + test('throw error when user does not exist', async ({ assert }) => { + assert.plan(8) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + try { + await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 10) // there is no user with id 10 + return guard.authenticate() + }) + } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) + assert.equal(error.message, 'Invalid or expired user session') + assert.equal(error.guardDriverName, 'session') + } + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + }) +}) + +test.group('Session guard | authenticate | via remember me cookie', () => { + test('create user session and mark them as logged-in via remember cookie', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + + const originalExpiryTime = token.expiresAt.getTime() + const originalUpdatedAtTime = token.updatedAt.getTime() + const originalHash = token.hash + const originalSeries = token.series + + userProvider.useToken(token) + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + const user = await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + + assert.isTrue(guard.authenticationAttempted) + assert.isTrue(guard.isAuthenticated) + assert.isDefined(guard.user) + assert.deepEqual(guard.user, user) + assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) + assert.isFalse(guard.isLoggedOut) + assert.isTrue(guard.viaRemember) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + + assert.equal(token.expiresAt.getTime(), originalExpiryTime) + assert.equal(token.updatedAt.getTime(), originalUpdatedAtTime) + assert.equal(token.hash, originalHash) + assert.equal(token.series, originalSeries) + }) + + test('recycle token when the existing token is older than 1 minute', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + const originalExpiryTime = token.expiresAt.getTime() + const originalUpdatedAtTime = token.updatedAt.getTime() + const originalHash = token.hash + const originalSeries = token.series + + userProvider.useToken(token) + timeTravel(120) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + const user = await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + + assert.isTrue(guard.authenticationAttempted) + assert.isTrue(guard.isAuthenticated) + assert.isDefined(guard.user) + assert.deepEqual(guard.user, user) + assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) + assert.isFalse(guard.isLoggedOut) + assert.isTrue(guard.viaRemember) + assert.deepEqual(ctx.session.all(), { auth_web: user.id }) + + assert.isAbove(token.expiresAt.getTime(), originalExpiryTime) + assert.isAbove(token.updatedAt.getTime(), originalUpdatedAtTime) + assert.notEqual(token.hash, originalHash) + assert.equal(token.series, originalSeries) + }) + + test('throw error when token has been expired', async ({ assert }) => { + assert.plan(9) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + + userProvider.useToken(token) + timeTravel(21 * 60) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) + assert.equal(error.message, 'Invalid or expired user session') + assert.equal(error.guardDriverName, 'session') + } + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + assert.deepEqual(ctx.session.all(), {}) + }) + + test('throw error when token does not exist', async ({ assert }) => { + assert.plan(9) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) + assert.equal(error.message, 'Invalid or expired user session') + assert.equal(error.guardDriverName, 'session') + } + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + assert.deepEqual(ctx.session.all(), {}) + }) + + test('throw error when remember me cookie does exist', async ({ assert }) => { + assert.plan(9) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + userProvider.useToken(token) + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) + assert.equal(error.message, 'Invalid or expired user session') + assert.equal(error.guardDriverName, 'session') + } + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + assert.deepEqual(ctx.session.all(), {}) + }) + + test('throw error when token is malformed', async ({ assert }) => { + assert.plan(9) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + + userProvider.useToken(token) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: 'foo', + type: 'encrypted', + }, + ]) + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) + assert.equal(error.message, 'Invalid or expired user session') + assert.equal(error.guardDriverName, 'session') + } + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + assert.deepEqual(ctx.session.all(), {}) + }) + + test('throw error when user does not exist', async ({ assert }) => { + assert.plan(9) + + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(10, '20 mins', 'web') + userProvider.useToken(token) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.authenticate() + }) + } catch (error) { + assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) + assert.equal(error.message, 'Invalid or expired user session') + assert.equal(error.guardDriverName, 'session') + } + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + assert.deepEqual(ctx.session.all(), {}) + }) +}) + +test.group('Session guard | authenticate | via session', () => { + test('multiple calls to authenticate should be a noop', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + /** + * Fails for the first time + */ + await assert.rejects(() => guard.authenticate()) + + /** + * Then we setup the session + */ + await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 1) + }) + + /** + * But still unauthenticated, because the method does not + * re-form the authentication + */ + await assert.rejects(() => guard.authenticate()) + + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + }) +}) + +test.group('Session guard | check', () => { + test('return true when user is logged-in', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const isLoggedIn = await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 1) // setting auth state for userId 1 + return guard.check() + }) + + assert.isTrue(isLoggedIn) + assert.isTrue(guard.authenticationAttempted) + assert.isTrue(guard.isAuthenticated) + assert.isDefined(guard.user) + assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + }) + + test('return false when user is not logged-in', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + const isLoggedIn = await sessionMiddleware.handle(ctx, () => { + return guard.check() + }) + + assert.isFalse(isLoggedIn) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.isAuthenticated) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + }) + + test('re-throw errors other than the E_UNAUTHORIZED_ACCESS', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + + await assert.rejects( + () => guard.check(), + 'Cannot authenticate user. Install and configure "@adonisjs/session" package' + ) + }) +}) + +test.group('Session guard | getUserOrFail', () => { + test('return user when user is logged-in', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 1) // setting auth state for userId 1 + return guard.authenticate() + }) + + assert.isTrue(guard.authenticationAttempted) + assert.isTrue(guard.isAuthenticated) + assert.deepEqual(guard.user, guard.getUserOrFail()) + assert.deepEqual(guard.getUserOrFail(), { + id: 1, + email: 'virk@adonisjs.com', + password: 'secret', + }) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.viaRemember) + }) + + test('throw error when user is not logged-in', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + await sessionMiddleware.handle(ctx, () => { + return guard.check() + }) + + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + }) +}) + +test.group('Session guard | authenticateAsClient', () => { + test('return session state for client login', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + assert.deepEqual( + await guard.authenticateAsClient((await userProvider.findById(1))!.getOriginal()), + { + session: { + auth_web: 1, + }, + } + ) + }) +}) diff --git a/tests/session_guard/remember_me_token.spec.ts b/tests/session_guard/remember_me_token.spec.ts new file mode 100644 index 0000000..fb5e151 --- /dev/null +++ b/tests/session_guard/remember_me_token.spec.ts @@ -0,0 +1,122 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createHash } from 'node:crypto' +import { setTimeout } from 'node:timers/promises' +import { Secret, base64 } from '@adonisjs/core/helpers' + +import { freezeTime } from '../helpers.js' +import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' + +test.group('Remember me token', () => { + test('create a remember me token', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = RememberMeToken.create(1, '20mins', 'web') + assert.equal(token.userId, 1) + assert.equal(token.createdAt.getTime(), date.getTime()) + assert.equal(token.updatedAt.getTime(), date.getTime()) + assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) + assert.lengthOf(token.series, 15) + assert.instanceOf(token.value, Secret) + assert.equal(token.guard, 'web') + assert.equal(token.type, 'remember_me_token') + assert.equal( + token.hash, + createHash('sha256') + .update(base64.urlDecode(token.value!.release().split('.')[1])!) + .digest('hex') + ) + assert.isFalse(token.isExpired()) + }) + + test('create token from persisted information', ({ assert }) => { + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) + + const token = RememberMeToken.createFromPersisted({ + userId: 1, + hash: '1234', + createdAt, + updatedAt, + expiresAt, + guard: 'web', + series: '1', + }) + + assert.equal(token.series, '1') + assert.equal(token.hash, '1234') + assert.equal(token.userId, 1) + assert.equal(token.guard, 'web') + assert.equal(token.type, 'remember_me_token') + assert.equal(token.userId, 1) + assert.equal(token.createdAt.getTime(), createdAt.getTime()) + assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) + assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) + assert.isUndefined(token.value) + assert.isFalse(token.isExpired()) + }) + + test('refresh remember me token', async ({ assert }) => { + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) + + const token = RememberMeToken.createFromPersisted({ + userId: 1, + hash: '1234', + createdAt, + updatedAt, + expiresAt, + guard: 'web', + series: '1', + }) + + await setTimeout(100) + token.refresh('20mins') + + assert.isDefined(token.value) + assert.notEqual(token.hash, '1234') + assert.isAbove(token.updatedAt.getTime(), updatedAt.getTime()) + assert.equal(token.expiresAt.getTime(), token.updatedAt.getTime() + 60 * 20 * 1000) + }) + + test('verify token hash', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = RememberMeToken.create(1, '20mins', 'web') + assert.isTrue(token.verify(base64.urlDecode(token.value!.release().split('.')[1])!)) + }) + + test('decode remember me token', ({ assert }) => { + const token = RememberMeToken.create(1, '20mins', 'web') + const { series, value } = RememberMeToken.decode(token.value!.release())! + + assert.equal(series, token.series) + assert.isTrue(token.verify(value)) + }) + + test('fail to decode invalid values', ({ assert }) => { + assert.isNull(RememberMeToken.decode(null as any)) + assert.isNull(RememberMeToken.decode('')) + assert.isNull(RememberMeToken.decode('...')) + assert.isNull(RememberMeToken.decode('foobar')) + assert.isNull(RememberMeToken.decode('foo.bar')) + }) +}) From 17bb572628900bdb71728f910d77d3f79299087b Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 12 Jan 2024 12:25:49 +0530 Subject: [PATCH 63/96] feat: implement attempt, loginViaId and login methods --- factories/session_guard/main.ts | 8 + modules/session_guard/guard.ts | 154 ++++++++++++++++- modules/session_guard/remember_me_token.ts | 2 +- src/errors.ts | 101 +++++++++++ tests/auth/e_invalid_credentials.spec.ts | 140 +++++++++++++++ ....spec.ts => e_unauthorized_access.spec.ts} | 0 tests/session_guard/login.spec.ts | 159 ++++++++++++++++++ 7 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 tests/auth/e_invalid_credentials.spec.ts rename tests/auth/{errors.spec.ts => e_unauthorized_access.spec.ts} (100%) create mode 100644 tests/session_guard/login.spec.ts diff --git a/factories/session_guard/main.ts b/factories/session_guard/main.ts index 76a7d63..87ab600 100644 --- a/factories/session_guard/main.ts +++ b/factories/session_guard/main.ts @@ -58,6 +58,10 @@ export class SessionFakeUserProvider implements SessionUserProviderContract> { return { getId() { @@ -120,4 +124,8 @@ export class SessionFakeUserProvider implements SessionUserProviderContract { this.#token = token } + + async createRememberMeToken(token: RememberMeToken): Promise { + this.#token = token + } } diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts index 4baa8a4..1687703 100644 --- a/modules/session_guard/guard.ts +++ b/modules/session_guard/guard.ts @@ -8,12 +8,12 @@ */ import type { HttpContext } from '@adonisjs/core/http' -import { RuntimeException } from '@adonisjs/core/exceptions' +import { Exception, RuntimeException } from '@adonisjs/core/exceptions' import type { EmitterLike } from '@adonisjs/core/types/events' import debug from './debug.js' import { RememberMeToken } from './remember_me_token.js' -import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import { E_INVALID_CREDENTIALS, E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' import type { AuthClientResponse, GuardContract } from '../../src/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' import type { @@ -152,6 +152,24 @@ export class SessionGuard { + const user = await this.verifyCredentials(uid, password) + return this.login(user, remember) + } + + /** + * Login a user by user id. Queries the user using the user provider + * and calls "login" method under the hood. + */ + async loginViaId(userId: string | number | BigInt, remember: boolean = false) { + debug('attempting to login user via id "%s"', userId) + + const providerUser = await this.#userProvider.findById(userId) + if (!providerUser) { + throw this.#loginFailed() + } + + return this.login(providerUser.getOriginal(), remember) + } + + /** + * Login a user by setting the session state. Optionally you + * can create the remember me cookie to have persistent + * login even after the session expires. + */ + async login( + user: UserProvider[typeof PROVIDER_REAL_USER], + remember: boolean = false + ): Promise { + this.#emitter.emit('session_auth:login_attempted', { + ctx: this.#ctx, + user, + guardName: this.#name, + }) + + /** + * Creating the provider user we can use to pull the + * user id + */ + const session = this.#getSession() + const providerUser = await this.#userProvider.createUserForGuard(user) + const userId = providerUser.getId() + + /** + * Create remember me token and persist it with the provider + * when remember me token is true. + */ + let token: RememberMeToken | undefined + if (remember) { + if (!this.#userProvider.createRememberMeToken) { + throw new Exception( + 'Cannot use "rememberMe" feature. The provider does not implement the "createRememberMeToken" method' + ) + } + + debug('creating remember me cookie') + token = RememberMeToken.create( + userId, + this.#config.rememberMeTokenAge || '2years', + this.#name + ) + await this.#userProvider.createRememberMeToken(token) + this.#createRememberMeCookie(token.value!.release()) + } else { + this.#ctx.response.clearCookie(this.rememberMeKeyName) + } + + /** + * Create session + */ + debug('marking user with id "%s" as logged-in', userId) + this.#createSessionForUser(userId) + + /** + * Update local state + */ + this.user = user + this.isLoggedOut = false + + /** + * Notify login succeeded + */ + this.#emitter.emit('session_auth:login_succeeded', { + ctx: this.#ctx, + guardName: this.#name, + user, + sessionId: session.sessionId, + }) + + return this.user + } + /** * Authenticates the current HTTP request by reading the userId * from the session and/or using the remember me token to have diff --git a/modules/session_guard/remember_me_token.ts b/modules/session_guard/remember_me_token.ts index 5aa9d49..d771d20 100644 --- a/modules/session_guard/remember_me_token.ts +++ b/modules/session_guard/remember_me_token.ts @@ -66,7 +66,7 @@ export class RememberMeToken { * timestamps */ static create( - userId: string | number, + userId: string | number | BigInt, expiry: string | number, guard: string, size: number = 30 diff --git a/src/errors.ts b/src/errors.ts index a4531e4..6909142 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -133,3 +133,104 @@ export const E_UNAUTHORIZED_ACCESS = class extends Exception { return renderer(message, error, ctx) } } + +/** + * Exception is raised when user credentials are invalid + */ +export const E_INVALID_CREDENTIALS = class extends Exception { + static status: number = 400 + static code: string = 'E_INVALID_CREDENTIALS' + + /** + * Translation identifier. Can be customized + */ + identifier: string = 'errors.E_INVALID_CREDENTIALS' + + /** + * The guard name reference that raised the exception. It allows + * us to customize the logic of handling the exception. + */ + guardDriverName: string + + /** + * A collection of renderers to render the exception to a + * response. + * + * The collection is a key-value pair, where the key is + * the guard driver name and value is a factory function + * to respond to the request. + */ + renderers: Record< + string, + (message: string, error: this, ctx: HttpContext) => Promise | void + > = { + /** + * Response when session driver is used + */ + session: (message, error, ctx) => { + switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { + case 'html': + case null: + ctx.session.flashExcept(['_csrf']) + ctx.session.flashErrors({ [error.code!]: message }) + ctx.response.redirect('back', true) + break + case 'json': + ctx.response.status(error.status).send({ + errors: [ + { + message, + }, + ], + }) + break + case 'application/vnd.api+json': + ctx.response.status(error.status).send({ + errors: [ + { + code: error.code, + title: message, + }, + ], + }) + break + } + }, + } + + /** + * Returns the message to be sent in the HTTP response. + * Feel free to override this method and return a custom + * response. + */ + getResponseMessage(error: this, ctx: HttpContext) { + if ('i18n' in ctx) { + return (ctx.i18n as I18n).t(error.identifier, {}, error.message) + } + return error.message + } + + constructor( + message: string, + options: { + guardDriverName: string + } + ) { + super(message, {}) + this.guardDriverName = options.guardDriverName + } + + /** + * Converts exception to an HTTP response + */ + async handle(error: this, ctx: HttpContext) { + const renderer = this.renderers[this.guardDriverName] + const message = error.getResponseMessage(error, ctx) + + if (!renderer) { + return ctx.response.status(error.status).send(message) + } + + return renderer(message, error, ctx) + } +} diff --git a/tests/auth/e_invalid_credentials.spec.ts b/tests/auth/e_invalid_credentials.spec.ts new file mode 100644 index 0000000..baabad6 --- /dev/null +++ b/tests/auth/e_invalid_credentials.spec.ts @@ -0,0 +1,140 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { I18nManagerFactory } from '@adonisjs/i18n/factories' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { E_INVALID_CREDENTIALS } from '../../src/errors.js' + +test.group('Errors | E_INVALID_CREDENTIALS | session', () => { + test('report error via flash messages and redirect', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = new E_INVALID_CREDENTIALS('Invalid credentials', { + guardDriverName: 'session', + }) + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.deepEqual(ctx.session.responseFlashMessages.all(), { + errorsBag: { E_INVALID_CREDENTIALS: 'Invalid credentials' }, + input: {}, + }) + assert.equal(ctx.response.getHeader('location'), '/') + }) + + test('respond with json', async ({ assert }) => { + const error = new E_INVALID_CREDENTIALS('Invalid credentials', { + guardDriverName: 'session', + }) + + const ctx = new HttpContextFactory().create() + + /** + * Force JSON response + */ + ctx.request.request.headers.accept = 'application/json' + await error.handle(error, ctx) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + message: 'Invalid credentials', + }, + ], + }) + }) + + test('respond with JSONAPI response', async ({ assert }) => { + const error = new E_INVALID_CREDENTIALS('Invalid credentials', { + guardDriverName: 'session', + }) + + const ctx = new HttpContextFactory().create() + + /** + * Force JSONAPI response + */ + ctx.request.request.headers.accept = 'application/vnd.api+json' + await error.handle(error, ctx) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + title: 'Invalid credentials', + code: 'E_INVALID_CREDENTIALS', + }, + ], + }) + }) + + test('translate error message using i18n', async ({ assert }) => { + const error = new E_INVALID_CREDENTIALS('Invalid credentials', { + guardDriverName: 'session', + }) + const i18nManager = new I18nManagerFactory() + .merge({ + config: { + loaders: [ + () => { + return { + async load() { + return { + en: { + 'errors.E_INVALID_CREDENTIALS': 'Incorrect email or password', + }, + } + }, + } + }, + ], + }, + }) + .create() + + const ctx = new HttpContextFactory().create() + await i18nManager.loadTranslations() + ctx.i18n = i18nManager.locale('en') + + /** + * Force JSON response + */ + ctx.request.request.headers.accept = 'application/json' + await error.handle(error, ctx) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + message: 'Incorrect email or password', + }, + ], + }) + }) +}) + +test.group('Errors | E_INVALID_CREDENTIALS | unknown guard', () => { + test('send plain text response', async ({ assert }) => { + const error = new E_INVALID_CREDENTIALS('Invalid credentials', { + guardDriverName: 'foo', + }) + + const ctx = new HttpContextFactory().create() + await error.handle(error, ctx) + + assert.equal(ctx.response.getStatus(), 400) + assert.equal(ctx.response.getBody(), 'Invalid credentials') + }) +}) diff --git a/tests/auth/errors.spec.ts b/tests/auth/e_unauthorized_access.spec.ts similarity index 100% rename from tests/auth/errors.spec.ts rename to tests/auth/e_unauthorized_access.spec.ts diff --git a/tests/session_guard/login.spec.ts b/tests/session_guard/login.spec.ts new file mode 100644 index 0000000..90e92e5 --- /dev/null +++ b/tests/session_guard/login.spec.ts @@ -0,0 +1,159 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { createEmitter, parseCookies } from '../helpers.js' +import { SessionGuard } from '../../modules/session_guard/guard.js' +import { SessionFakeUserProvider } from '../../factories/session_guard/main.js' +import { E_INVALID_CREDENTIALS } from '../../src/errors.js' + +test.group('Session guard | login', () => { + test('create session for the user', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const user = await userProvider.findById(1) + + await sessionMiddleware.handle(ctx, () => { + return guard.login(user!.getOriginal()) + }) + + assert.deepEqual(ctx.session.all(), { auth_web: 1 }) + assert.deepEqual(guard.user, user!.getOriginal()) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + }) + + test('generate remember me token cookie', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const user = await userProvider.findById(1) + + await sessionMiddleware.handle(ctx, () => { + return guard.login(user!.getOriginal(), true) + }) + + assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { + remember_web: { + httpOnly: true, + name: 'remember_web', + path: '/', + value: userProvider.getToken()!.value!.release(), + }, + }) + assert.deepEqual(ctx.session.all(), { auth_web: 1 }) + assert.deepEqual(guard.user, user!.getOriginal()) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + }) +}) + +test.group('Session guard | loginViaId', () => { + test('create session for the user by id', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, () => { + return guard.loginViaId(1) + }) + + assert.deepEqual(ctx.session.all(), { auth_web: 1 }) + assert.deepEqual(guard.user, { email: 'virk@adonisjs.com', id: 1, password: 'secret' }) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + }) + + test('throw error when user for the id does not exist', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.loginViaId(10) + }) + } catch (error) { + assert.instanceOf(error, E_INVALID_CREDENTIALS) + assert.equal(error.message, 'Invalid user credentails') + assert.equal(error.guardDriverName, 'session') + } + + assert.deepEqual(ctx.session.all(), {}) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + }) +}) + +test.group('Session guard | attempt', () => { + test('create session for the user after verifying credentials', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, () => { + return guard.attempt('virk@adonisjs.com', 'secret') + }) + + assert.deepEqual(ctx.session.all(), { auth_web: 1 }) + assert.deepEqual(guard.user, { email: 'virk@adonisjs.com', id: 1, password: 'secret' }) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + }) + + test('throw error when credentials are invalid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + try { + await sessionMiddleware.handle(ctx, () => { + return guard.attempt('virk@adonisjs.com', 'foo') + }) + } catch (error) { + assert.instanceOf(error, E_INVALID_CREDENTIALS) + assert.equal(error.message, 'Invalid user credentails') + assert.equal(error.guardDriverName, 'session') + } + + assert.deepEqual(ctx.session.all(), {}) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + }) +}) From c5c2f90aa09c9b176e79c24923a4b69301f0d8d2 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 12 Jan 2024 12:36:24 +0530 Subject: [PATCH 64/96] feat: implement logout method --- factories/session_guard/main.ts | 6 + modules/session_guard/guard.ts | 57 ++++++++ .../{login.spec.ts => guard_login.spec.ts} | 0 tests/session_guard/guard_logout.spec.ts | 128 ++++++++++++++++++ 4 files changed, 191 insertions(+) rename tests/session_guard/{login.spec.ts => guard_login.spec.ts} (100%) create mode 100644 tests/session_guard/guard_logout.spec.ts diff --git a/factories/session_guard/main.ts b/factories/session_guard/main.ts index 87ab600..62e8b49 100644 --- a/factories/session_guard/main.ts +++ b/factories/session_guard/main.ts @@ -128,4 +128,10 @@ export class SessionFakeUserProvider implements SessionUserProviderContract { this.#token = token } + + async deleteRememberMeTokenBySeries(series: string): Promise { + if (this.#token && this.#token.series === series) { + this.#token = undefined + } + } } diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts index 1687703..71f9d0e 100644 --- a/modules/session_guard/guard.ts +++ b/modules/session_guard/guard.ts @@ -560,6 +560,63 @@ export class SessionGuard { + test('delete user session', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + + await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 1) + return guard.logout() + }) + + assert.deepEqual(ctx.session.all(), {}) + assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { + remember_web: { + maxAge: -1, + name: 'remember_web', + value: '', + expires: new Date(0), + }, + }) + assert.isUndefined(guard.user) + assert.isTrue(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + }) + + test('delete remember token when one exists', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + userProvider.useToken(token) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 1) + return guard.logout() + }) + + assert.deepEqual(ctx.session.all(), {}) + assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { + remember_web: { + maxAge: -1, + name: 'remember_web', + value: '', + expires: new Date(0), + }, + }) + assert.isUndefined(guard.user) + assert.isTrue(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isUndefined(userProvider.getToken()) + }) + + test('do not delete token when value in cookie is invalid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const token = RememberMeToken.create(1, '20 mins', 'web') + userProvider.useToken(token) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: 'foo', + type: 'encrypted', + }, + ]) + + await sessionMiddleware.handle(ctx, () => { + ctx.session.put('auth_web', 1) + return guard.logout() + }) + + assert.deepEqual(ctx.session.all(), {}) + assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { + remember_web: { + maxAge: -1, + name: 'remember_web', + value: '', + expires: new Date(0), + }, + }) + assert.isUndefined(guard.user) + assert.isTrue(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isDefined(userProvider.getToken()) + }) +}) From 71083a314c5afe08c0718121b2c9bd310ec71266 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 12 Jan 2024 14:13:04 +0530 Subject: [PATCH 65/96] feat: implement session user provider base methods --- bin/test.ts | 6 +- .../session_guard/models/remember_me_token.ts | 36 ++ modules/session_guard/types.ts | 57 ++- modules/session_guard/user_providers/lucid.ts | 220 ++++++++++ tests/helpers.ts | 5 +- .../guard/authenticate.spec.ts} | 10 +- .../guard/login.spec.ts} | 8 +- .../guard/logout.spec.ts} | 8 +- .../remember_me_token.spec.ts | 0 tests/session/user_providers/lucid.spec.ts | 378 ++++++++++++++++++ 10 files changed, 705 insertions(+), 23 deletions(-) create mode 100644 modules/session_guard/models/remember_me_token.ts create mode 100644 modules/session_guard/user_providers/lucid.ts rename tests/{session_guard/guard_authenticate.spec.ts => session/guard/authenticate.spec.ts} (97%) rename tests/{session_guard/guard_login.spec.ts => session/guard/login.spec.ts} (95%) rename tests/{session_guard/guard_logout.spec.ts => session/guard/logout.spec.ts} (92%) rename tests/{session_guard => session}/remember_me_token.spec.ts (100%) create mode 100644 tests/session/user_providers/lucid.spec.ts diff --git a/bin/test.ts b/bin/test.ts index 962247d..9f51d35 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -9,11 +9,7 @@ configure({ suites: [ { name: 'session', - files: ['tests/session_guard/**/*.spec.ts'], - }, - { - name: 'basic_auth', - files: ['tests/basic_auth_guard/**/*.spec.ts'], + files: ['tests/session/**/*.spec.ts'], }, { name: 'auth', diff --git a/modules/session_guard/models/remember_me_token.ts b/modules/session_guard/models/remember_me_token.ts new file mode 100644 index 0000000..3399a88 --- /dev/null +++ b/modules/session_guard/models/remember_me_token.ts @@ -0,0 +1,36 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseModel, column } from '@adonisjs/lucid/orm' + +export class RememberMeTokenModel extends BaseModel { + @column() + declare series: string + + @column() + declare userId: number | string | BigInt + + @column() + declare hash: string + + @column() + declare type: string + + @column() + declare guard: string + + @column() + declare createdAt: Date + + @column() + declare updatedAt: Date + + @column() + declare expiresAt: Date +} diff --git a/modules/session_guard/types.ts b/modules/session_guard/types.ts index 5e58ed1..3ba0c8e 100644 --- a/modules/session_guard/types.ts +++ b/modules/session_guard/types.ts @@ -7,12 +7,65 @@ * file that was distributed with this source code. */ +import { HashersList } from '@adonisjs/core/types' import type { HttpContext } from '@adonisjs/core/http' +import { LucidModel } from '@adonisjs/lucid/types/model' import type { Exception } from '@adonisjs/core/exceptions' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import type { RememberMeTokenModel } from './models/remember_me_token.js' import type { RememberMeToken } from './remember_me_token.js' import type { PROVIDER_REAL_USER } from '../../src/symbols.js' +/** + * Options accepted by the Session Lucid user provider + */ +export type SessionLucidUserProviderOptions = { + /** + * Define the hasher to use to hash and verify + * passwords + */ + hasher?: keyof HashersList + + /** + * Optionally define the connection to use when making database + * queries + */ + connection?: string + + /** + * Model to use for authentication + */ + model: () => Promise<{ default: Model }> + + /** + * Column name to read the hashed password + */ + passwordColumnName: Extract, string> + + /** + * An array of uids to use when finding a user for login. Make + * sure all fields can be used to uniquely lookup a user. + */ + uids: Extract, string>[] +} + +/** + * A lucid model that can be used during authentication + */ +export type LucidAuthenticatable = LucidModel & { + /** + * HasMany relationship to manage rememberMe tokens + */ + rememberMeTokens?: HasMany + + /** + * Optional static method to customize the user lookup + * during "findByUid" method call. + */ + getUserForAuth?(uids: string[], value: string | number): Promise +} + /** * Guard user is an adapter between the user provider * and the guard. @@ -44,13 +97,13 @@ export interface SessionUserProviderContract { * * This method is called when finding a user for login */ - findByUid(userId: string | number): Promise | null> + findByUid(uid: string | number): Promise | null> /** * Find a user by unique primary id. This method is called when * authenticating user from their session. */ - findById(uid: string | number | BigInt): Promise | null> + findById(userId: string | number | BigInt): Promise | null> /** * Find a user by uid and verify their password. This method prevents diff --git a/modules/session_guard/user_providers/lucid.ts b/modules/session_guard/user_providers/lucid.ts new file mode 100644 index 0000000..9f0c692 --- /dev/null +++ b/modules/session_guard/user_providers/lucid.ts @@ -0,0 +1,220 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Hash } from '@adonisjs/core/hash' +import { RuntimeException } from '@poppinss/utils' + +import debug from '../debug.js' +import { PROVIDER_REAL_USER } from '../../../src/symbols.js' +import type { + GuardUser, + LucidAuthenticatable, + SessionUserProviderContract, + SessionLucidUserProviderOptions, +} from '../types.js' + +/** + * Lucid user represents a guard user, used by authentication guards + * to perform authentication. + */ +class LucidUser> + implements GuardUser +{ + constructor(public realUser: RealUser) {} + + /** + * @inheritdoc + */ + getId(): string | number { + const id = this.realUser.$primaryKeyValue + + /** + * Ensure id exists + */ + if (!id) { + const model = this.realUser.constructor as LucidAuthenticatable + const modelName = model.name + const primaryKey = model.primaryKey + throw new RuntimeException( + `Cannot use "${modelName}" model for authentication. The value of column "${primaryKey}" is undefined or null` + ) + } + + return id + } + + /** + * Returns the original user by reference + */ + getOriginal(): RealUser { + return this.realUser + } +} + +/** + * Implementation of session user provider that uses lucid models + * to find user and remember me tokens + */ +export class SessionLucidUserProvider + implements SessionUserProviderContract> +{ + declare [PROVIDER_REAL_USER]: InstanceType + + /** + * Reference to the lazily imported model + */ + protected model?: UserModel + + constructor( + /** + * Hasher is used to verify plain text passwords + */ + protected hasher: Hash, + + /** + * Lucid provider options + */ + protected options: SessionLucidUserProviderOptions + ) {} + + /** + * Imports the model from the provider, returns and caches it + * for further operations. + */ + protected async getModel() { + if (this.model) { + return this.model + } + + const importedModel = await this.options.model() + this.model = importedModel.default + debug('lucid_user_provider: using model [class %s]', this.model.name) + return this.model + } + + /** + * Returns an instance of the query builder + */ + protected getQueryBuilder(model: UserModel) { + return model.query({ + connection: this.options.connection, + }) + } + + /** + * Returns an instance of the "LucidUser" that guards + * can use for authentication + */ + async createUserForGuard(user: InstanceType) { + const model = await this.getModel() + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + debug('lucid_user_provider: converting user object to guard user %O', user) + return new LucidUser(user) + } + + /** + * Finds a user by id using the configured model. + */ + async findById(value: string | number): Promise> | null> { + debug('lucid_user_provider: finding user by id %s', value) + + const model = await this.getModel() + const user = await model.find(value, { + connection: this.options.connection, + }) + + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + /** + * Finds the user by uid and returns an instance of the guard user + */ + async findByUid(uid: string | number): Promise> | null> { + const model = await this.getModel() + + /** + * Use custom lookup method when defined on the model. + */ + if ('getUserForAuth' in model && typeof model.getUserForAuth === 'function') { + debug('lucid_user_provider: using getUserForAuth method on "[class %s]"', model.name) + + const user = await model.getUserForAuth(this.options.uids, uid) + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + /** + * Self query + */ + debug('lucid_user_provider: finding user by uids: %O, value: %s', this.options.uids, uid) + const query = this.getQueryBuilder(model) + this.options.uids.forEach((uidColumn) => query.orWhere(uidColumn, uid)) + + const user = await query.limit(1).first() + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + /** + * Find a user by uid and verify their password. This method prevents + * timing attacks. + */ + async verifyCredentials( + uid: string | number, + password: string + ): Promise> | null> { + const user = await this.findByUid(uid) + + /** + * Hashing the password to prevent timing attacks. + */ + if (!user) { + await this.hasher.make(password) + return null + } + + /** + * Check the password hash exists on the model or thrown + * an error + */ + const passwordHash = user.getOriginal()[this.options.passwordColumnName] + if (!passwordHash) { + throw new RuntimeException( + `Cannot verify password during login. The value of column "${this.options.passwordColumnName}" is undefined or null` + ) + } + + /** + * Verify password + */ + if (await this.hasher.verify(passwordHash as string, password)) { + return user + } + + /** + * Invalid password, return null + */ + return null + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 3c22939..e5337ca 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -23,9 +23,8 @@ import setCookieParser, { CookieMap } from 'set-cookie-parser' import { LoggerFactory } from '@adonisjs/core/factories/logger' import { EncryptionFactory } from '@adonisjs/core/factories/encryption' -import { SessionGuardEvents } from '../src/guards/session/types.js' +import { SessionGuardEvents } from '../modules/session_guard/types.js' import { FactoryUser } from '../backup/factories/core/lucid_user_provider.js' -import { BasicAuthGuardEvents } from '../src/guards/basic_auth/types.js' export const encryption: Encryption = new EncryptionFactory().create() @@ -123,7 +122,7 @@ export function createEmitter() { } const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) - return new Emitter & BasicAuthGuardEvents>(app) + return new Emitter>(app) } /** diff --git a/tests/session_guard/guard_authenticate.spec.ts b/tests/session/guard/authenticate.spec.ts similarity index 97% rename from tests/session_guard/guard_authenticate.spec.ts rename to tests/session/guard/authenticate.spec.ts index a767e33..5679317 100644 --- a/tests/session_guard/guard_authenticate.spec.ts +++ b/tests/session/guard/authenticate.spec.ts @@ -11,11 +11,11 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { createEmitter, defineCookies, timeTravel } from '../helpers.js' -import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' -import { SessionGuard } from '../../modules/session_guard/guard.js' -import { SessionFakeUserProvider } from '../../factories/session_guard/main.js' -import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' +import { createEmitter, defineCookies, timeTravel } from '../../helpers.js' +import { E_UNAUTHORIZED_ACCESS } from '../../../src/errors.js' +import { SessionGuard } from '../../../modules/session_guard/guard.js' +import { SessionFakeUserProvider } from '../../../factories/session_guard/main.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' test.group('Session guard | authenticate | via session', () => { test('mark user as logged-in when a valid session exists', async ({ assert }) => { diff --git a/tests/session_guard/guard_login.spec.ts b/tests/session/guard/login.spec.ts similarity index 95% rename from tests/session_guard/guard_login.spec.ts rename to tests/session/guard/login.spec.ts index 90e92e5..6314173 100644 --- a/tests/session_guard/guard_login.spec.ts +++ b/tests/session/guard/login.spec.ts @@ -11,10 +11,10 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { createEmitter, parseCookies } from '../helpers.js' -import { SessionGuard } from '../../modules/session_guard/guard.js' -import { SessionFakeUserProvider } from '../../factories/session_guard/main.js' -import { E_INVALID_CREDENTIALS } from '../../src/errors.js' +import { createEmitter, parseCookies } from '../../helpers.js' +import { SessionGuard } from '../../../modules/session_guard/guard.js' +import { SessionFakeUserProvider } from '../../../factories/session_guard/main.js' +import { E_INVALID_CREDENTIALS } from '../../../src/errors.js' test.group('Session guard | login', () => { test('create session for the user', async ({ assert }) => { diff --git a/tests/session_guard/guard_logout.spec.ts b/tests/session/guard/logout.spec.ts similarity index 92% rename from tests/session_guard/guard_logout.spec.ts rename to tests/session/guard/logout.spec.ts index f598cb6..b0434e6 100644 --- a/tests/session_guard/guard_logout.spec.ts +++ b/tests/session/guard/logout.spec.ts @@ -11,10 +11,10 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { createEmitter, defineCookies, parseCookies } from '../helpers.js' -import { SessionGuard } from '../../modules/session_guard/guard.js' -import { SessionFakeUserProvider } from '../../factories/session_guard/main.js' -import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' +import { createEmitter, defineCookies, parseCookies } from '../../helpers.js' +import { SessionGuard } from '../../../modules/session_guard/guard.js' +import { SessionFakeUserProvider } from '../../../factories/session_guard/main.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' test.group('Session guard | logout', () => { test('delete user session', async ({ assert }) => { diff --git a/tests/session_guard/remember_me_token.spec.ts b/tests/session/remember_me_token.spec.ts similarity index 100% rename from tests/session_guard/remember_me_token.spec.ts rename to tests/session/remember_me_token.spec.ts diff --git a/tests/session/user_providers/lucid.spec.ts b/tests/session/user_providers/lucid.spec.ts new file mode 100644 index 0000000..cc3e742 --- /dev/null +++ b/tests/session/user_providers/lucid.spec.ts @@ -0,0 +1,378 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import convertHrtime from 'convert-hrtime' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { createDatabase, createTables, getHasher } from '../../helpers.js' +import { SessionLucidUserProvider } from '../../../modules/session_guard/user_providers/lucid.js' + +class User extends BaseModel { + @column() + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string | null +} + +test.group('Session lucid user provider | findById', () => { + test('return guard user instance', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await User.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const user = await userProvider.findById(1) + + expectTypeOf(user!.getOriginal()).toEqualTypeOf() + assert.instanceOf(user!.getOriginal(), User) + assert.equal(user!.getId(), 1) + }) + + test('return null when no user exists', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + const user = await userProvider.findById(1) + assert.isNull(user) + }) +}) + +test.group('Session lucid user provider | findByUid', () => { + test('return guard user instance', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await User.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const user = await userProvider.findByUid('virk@adonisjs.com') + + expectTypeOf(user!.getOriginal()).toEqualTypeOf() + assert.instanceOf(user!.getOriginal(), User) + }) + + test('return null when no user exists', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + const user = await userProvider.findByUid(1) + assert.isNull(user) + }) + + test('use custom lookup method when defined on the model', async ({ assert, expectTypeOf }) => { + assert.plan(2) + + const db = await createDatabase() + await createTables(db) + + class AuthUser extends User { + static async getUserForAuth(uids: string[], value: string | number) { + assert.deepEqual(uids, ['username', 'email']) + + const query = this.query() + uids.forEach((uid) => query.orWhere(uid, value)) + return query.first() + } + } + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await User.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const user = await userProvider.findByUid('virk@adonisjs.com') + + expectTypeOf(user!.getOriginal()).toEqualTypeOf() + assert.instanceOf(user!.getOriginal(), User) + }) + + test('return null when custom method does not return a user', async ({ assert }) => { + assert.plan(2) + + const db = await createDatabase() + await createTables(db) + + class AuthUser extends User { + static async getUserForAuth(uids: string[], value: string | number) { + assert.deepEqual(uids, ['username', 'email']) + + const query = this.query() + uids.forEach((uid) => query.orWhere(uid, value)) + return query.first() + } + } + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + const user = await userProvider.findByUid('virk@adonisjs.com') + assert.isNull(user) + }) +}) + +test.group('Session lucid user provider | verifyCredentials', () => { + test('return guard user instance when credentials are valid', async ({ + assert, + expectTypeOf, + }) => { + const db = await createDatabase() + const hasher = getHasher() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(hasher, { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await User.create({ + email: 'virk@adonisjs.com', + password: await hasher.make('secret'), + username: 'virk', + }) + const user = await userProvider.verifyCredentials('virk@adonisjs.com', 'secret') + + expectTypeOf(user!.getOriginal()).toEqualTypeOf() + assert.instanceOf(user!.getOriginal(), User) + }) + + test('return null when password is invalid', async ({ assert }) => { + const db = await createDatabase() + const hasher = getHasher() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(hasher, { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await User.create({ + email: 'virk@adonisjs.com', + password: await hasher.make('secret'), + username: 'virk', + }) + + const user = await userProvider.verifyCredentials('virk@adonisjs.com', 'supersecret') + assert.isNull(user) + }) + + test('return null when unable to find user', async ({ assert }) => { + const db = await createDatabase() + const hasher = getHasher() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(hasher, { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + const user = await userProvider.verifyCredentials('virk@adonisjs.com', 'secret') + assert.isNull(user) + }) + + test('throw error when user does not have a password column', async ({ assert }) => { + const db = await createDatabase() + const hasher = getHasher() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(hasher, { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await User.create({ + email: 'virk@adonisjs.com', + password: null, + username: 'virk', + }) + + await assert.rejects( + () => userProvider.verifyCredentials('virk@adonisjs.com', 'secret'), + 'Cannot verify password during login. The value of column "password" is undefined or null' + ) + }) + + test('prevent timing attacks', async ({ assert }) => { + const db = await createDatabase() + const hasher = getHasher() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(hasher, { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await User.create({ + email: 'virk@adonisjs.com', + password: await hasher.make('secret'), + username: 'virk', + }) + + let startTime = process.hrtime.bigint() + await userProvider.verifyCredentials('foo@adonisjs.com', 'secret') + const invalidEmailTime = convertHrtime(process.hrtime.bigint() - startTime) + + startTime = process.hrtime.bigint() + await userProvider.verifyCredentials('virk@adonisjs.com', 'supersecret') + const invalidPasswordTime = convertHrtime(process.hrtime.bigint() - startTime) + + /** + * Same timing within the range of 10 milliseconds is acceptable + */ + assert.isBelow(Math.abs(invalidPasswordTime.seconds - invalidEmailTime.seconds), 1) + assert.isBelow(Math.abs(invalidPasswordTime.milliseconds - invalidEmailTime.milliseconds), 10) + }) +}) + +test.group('Session lucid user provider | guardUser', () => { + test('create guard user from model instance', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + password: 'secret', + username: 'virk', + }) + const guardUser = await userProvider.createUserForGuard(user) + + expectTypeOf(guardUser!.getOriginal()).toEqualTypeOf() + assert.instanceOf(guardUser!.getOriginal(), User) + assert.strictEqual(guardUser!.getOriginal(), user) + }) + + test('throw error when user input is invalid', async () => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + // @ts-expect-error + await userProvider.createUserForGuard({}) + }).throws('Invalid user object. It must be an instance of the "User" model') + + test('throw error when user does not have an id', async () => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + const user = await userProvider.createUserForGuard(new User()) + user.getId() + }).throws( + 'Cannot use "User" model for authentication. The value of column "id" is undefined or null' + ) +}) From 2330b1cdccc4cd774619146d8f13e901e9f27f2d Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 12 Jan 2024 15:14:37 +0530 Subject: [PATCH 66/96] feat: implement remember tokens API in session lucid user provider --- modules/session_guard/guard.ts | 2 +- .../session_guard/models/remember_me_token.ts | 21 +- modules/session_guard/types.ts | 3 +- modules/session_guard/user_providers/lucid.ts | 90 +++++- tests/helpers.ts | 2 +- tests/session/user_providers/lucid.spec.ts | 282 +++++++++++++++++- 6 files changed, 392 insertions(+), 8 deletions(-) diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts index 71f9d0e..a2bd50f 100644 --- a/modules/session_guard/guard.ts +++ b/modules/session_guard/guard.ts @@ -13,9 +13,9 @@ import type { EmitterLike } from '@adonisjs/core/types/events' import debug from './debug.js' import { RememberMeToken } from './remember_me_token.js' -import { E_INVALID_CREDENTIALS, E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' import type { AuthClientResponse, GuardContract } from '../../src/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' +import { E_INVALID_CREDENTIALS, E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' import type { SessionGuardConfig, SessionGuardEvents, diff --git a/modules/session_guard/models/remember_me_token.ts b/modules/session_guard/models/remember_me_token.ts index 3399a88..45533fe 100644 --- a/modules/session_guard/models/remember_me_token.ts +++ b/modules/session_guard/models/remember_me_token.ts @@ -8,9 +8,16 @@ */ import { BaseModel, column } from '@adonisjs/lucid/orm' +import { NormalizeConstructor } from '@adonisjs/core/types/helpers' export class RememberMeTokenModel extends BaseModel { - @column() + /** + * The series property is the primary key + */ + static selfAssignPrimaryKey = true + static table = 'remember_me_tokens' + + @column({ isPrimary: true }) declare series: string @column() @@ -34,3 +41,15 @@ export class RememberMeTokenModel extends BaseModel { @column() declare expiresAt: Date } + +/** + * Mixin to add support for remember me tokens on a + * user model + */ +export function withRememberMeTokens() { + return >(superclass: T) => { + return class extends superclass { + static rememberMeTokens = RememberMeTokenModel + } + } +} diff --git a/modules/session_guard/types.ts b/modules/session_guard/types.ts index 3ba0c8e..39e8c8f 100644 --- a/modules/session_guard/types.ts +++ b/modules/session_guard/types.ts @@ -11,7 +11,6 @@ import { HashersList } from '@adonisjs/core/types' import type { HttpContext } from '@adonisjs/core/http' import { LucidModel } from '@adonisjs/lucid/types/model' import type { Exception } from '@adonisjs/core/exceptions' -import type { HasMany } from '@adonisjs/lucid/types/relations' import type { RememberMeTokenModel } from './models/remember_me_token.js' import type { RememberMeToken } from './remember_me_token.js' @@ -57,7 +56,7 @@ export type LucidAuthenticatable = LucidModel & { /** * HasMany relationship to manage rememberMe tokens */ - rememberMeTokens?: HasMany + rememberMeTokens?: typeof RememberMeTokenModel /** * Optional static method to customize the user lookup diff --git a/modules/session_guard/user_providers/lucid.ts b/modules/session_guard/user_providers/lucid.ts index 9f0c692..7228e40 100644 --- a/modules/session_guard/user_providers/lucid.ts +++ b/modules/session_guard/user_providers/lucid.ts @@ -11,6 +11,7 @@ import { Hash } from '@adonisjs/core/hash' import { RuntimeException } from '@poppinss/utils' import debug from '../debug.js' +import { RememberMeToken } from '../remember_me_token.js' import { PROVIDER_REAL_USER } from '../../../src/symbols.js' import type { GuardUser, @@ -83,6 +84,21 @@ export class SessionLucidUserProvider protected options: SessionLucidUserProviderOptions ) {} + /** + * Returns the remember me model associated with + * user model + */ + protected async getRememberMeModel() { + const model = await this.getModel() + if (!model.rememberMeTokens) { + throw new RuntimeException( + `Cannot perist remember me token using "${model.name}" model. Make sure to use "withRememberMeTokens" mixin` + ) + } + + return model.rememberMeTokens + } + /** * Imports the model from the provider, returns and caches it * for further operations. @@ -126,7 +142,9 @@ export class SessionLucidUserProvider /** * Finds a user by id using the configured model. */ - async findById(value: string | number): Promise> | null> { + async findById( + value: string | number | BigInt + ): Promise> | null> { debug('lucid_user_provider: finding user by id %s', value) const model = await this.getModel() @@ -150,7 +168,7 @@ export class SessionLucidUserProvider /** * Use custom lookup method when defined on the model. */ - if ('getUserForAuth' in model && typeof model.getUserForAuth === 'function') { + if (typeof model.getUserForAuth === 'function') { debug('lucid_user_provider: using getUserForAuth method on "[class %s]"', model.name) const user = await model.getUserForAuth(this.options.uids, uid) @@ -217,4 +235,72 @@ export class SessionLucidUserProvider */ return null } + + /** + * Persists the remember token to the database using the + * model.rememberMeTokens property + */ + async createRememberMeToken(token: RememberMeToken): Promise { + const rememberMeModel = await this.getRememberMeModel() + await rememberMeModel.create({ + userId: token.userId, + createdAt: token.createdAt, + updatedAt: token.createdAt, + expiresAt: token.expiresAt, + guard: token.guard, + hash: token.hash, + series: token.series, + type: token.type, + }) + } + + /** + * Finds a remember me token for a user by the series. + * Uses model.rememberMeTokens property + */ + async findRememberMeTokenBySeries(series: string): Promise { + const rememberMeModel = await this.getRememberMeModel() + const token = await rememberMeModel.query().where('series', series).limit(1).first() + if (!token) { + return null + } + + const rememberMeToken = RememberMeToken.createFromPersisted({ + createdAt: typeof token.createdAt === 'number' ? new Date(token.createdAt) : token.createdAt, + updatedAt: typeof token.updatedAt === 'number' ? new Date(token.updatedAt) : token.updatedAt, + expiresAt: typeof token.expiresAt === 'number' ? new Date(token.expiresAt) : token.expiresAt, + guard: token.guard, + hash: token.hash, + series: token.series, + userId: token.userId, + }) + + if (rememberMeToken.isExpired() || token.type !== rememberMeToken.type) { + return null + } + + return rememberMeToken + } + + /** + * Updates the remember me token with new attributes. Uses + * model.rememberMeTokens property + */ + async recycleRememberMeToken(token: RememberMeToken): Promise { + const rememberMeModel = await this.getRememberMeModel() + await rememberMeModel.query().where('series', token.series).update({ + hash: token.hash, + updatedAt: token.updatedAt, + expiresAt: token.expiresAt, + }) + } + + /** + * Deletes an existing remember me token. Uses model.rememberMeTokens + * property. + */ + async deleteRememberMeTokenBySeries(series: string): Promise { + const rememberMeModel = await this.getRememberMeModel() + await rememberMeModel.query().where('series', series).del() + } } diff --git a/tests/helpers.ts b/tests/helpers.ts index e5337ca..81514b8 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -104,7 +104,7 @@ export async function createTables(db: Database) { table.integer('user_id').notNullable().unsigned() table.string('type').notNullable() table.string('guard').notNullable() - table.string('token', 80).notNullable() + table.string('hash', 80).notNullable() table.datetime('created_at').notNullable() table.datetime('updated_at').notNullable() table.datetime('expires_at').notNullable() diff --git a/tests/session/user_providers/lucid.spec.ts b/tests/session/user_providers/lucid.spec.ts index cc3e742..5d9c770 100644 --- a/tests/session/user_providers/lucid.spec.ts +++ b/tests/session/user_providers/lucid.spec.ts @@ -10,8 +10,11 @@ import { test } from '@japa/runner' import convertHrtime from 'convert-hrtime' import { BaseModel, column } from '@adonisjs/lucid/orm' -import { createDatabase, createTables, getHasher } from '../../helpers.js' +import { createDatabase, createTables, getHasher, timeTravel } from '../../helpers.js' import { SessionLucidUserProvider } from '../../../modules/session_guard/user_providers/lucid.js' +import { compose } from '@poppinss/utils' +import { withRememberMeTokens } from '../../../modules/session_guard/models/remember_me_token.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' class User extends BaseModel { @column() @@ -376,3 +379,280 @@ test.group('Session lucid user provider | guardUser', () => { 'Cannot use "User" model for authentication. The value of column "id" is undefined or null' ) }) + +test.group('Session lucid user provider | rememberTokens | create', () => { + test('throw error when not using withRememberMeTokens mixin', async () => { + const db = await createDatabase() + await createTables(db) + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: User, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + const token = RememberMeToken.create(1, '20 mins', 'web') + await userProvider.createRememberMeToken(token) + }).throws( + 'Cannot perist remember me token using "User" model. Make sure to use "withRememberMeTokens" mixin' + ) + + test('create a token for the user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + await userProvider.createRememberMeToken(token) + + const tokens = await AuthUser.rememberMeTokens.all() + assert.deepEqual(tokens[0].$attributes, { + userId: 1, + createdAt: token.createdAt.getTime(), + updatedAt: token.updatedAt.getTime(), + expiresAt: token.expiresAt.getTime(), + type: token.type, + series: token.series, + guard: token.guard, + hash: token.hash, + }) + }) +}) + +test.group('Session lucid user provider | rememberTokens | find', () => { + test('find token by series', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + await userProvider.createRememberMeToken(token) + const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) + + assert.instanceOf(rememberMeToken, RememberMeToken) + assert.equal(rememberMeToken!.expiresAt.getTime(), token.expiresAt.getTime()) + assert.equal(rememberMeToken!.updatedAt.getTime(), token.updatedAt.getTime()) + assert.equal(rememberMeToken!.createdAt.getTime(), token.createdAt.getTime()) + assert.equal(rememberMeToken!.hash, token.hash) + assert.equal(rememberMeToken!.series, token.series) + assert.equal(rememberMeToken!.type, token.type) + assert.equal(rememberMeToken!.guard, token.guard) + }) + + test('return null when token is missing', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + + const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) + assert.isNull(rememberMeToken) + }) + + test('return null when token has been expired', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + await userProvider.createRememberMeToken(token) + + timeTravel(21 * 60) + + const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) + assert.isNull(rememberMeToken) + }) + + test('return null when token type mismatches', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + await userProvider.createRememberMeToken(token) + await AuthUser.rememberMeTokens.query().where('series', token.series).update({ type: 'foo' }) + + const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) + assert.isNull(rememberMeToken) + }) +}) + +test.group('Session lucid user provider | rememberTokens | recycle', () => { + test('update token hash and timestamps', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + const existingHash = token.hash + const existingExpiresAt = token.expiresAt.getTime() + const existingUpdateAt = token.updatedAt.getTime() + + await userProvider.createRememberMeToken(token) + + token.refresh('30 mins') + await userProvider.recycleRememberMeToken(token) + + const tokens = await AuthUser.rememberMeTokens.all() + assert.equal(tokens[0].hash, token.hash) + assert.equal(tokens[0].expiresAt, token.expiresAt.getTime()) + assert.equal(tokens[0].updatedAt, token.updatedAt.getTime()) + assert.notEqual(tokens[0].expiresAt, existingExpiresAt) + assert.notEqual(tokens[0].updatedAt, existingUpdateAt) + assert.notEqual(tokens[0].hash, existingHash) + }) + + test('noop when no tokens exists in first place', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + token.refresh('30 mins') + await userProvider.recycleRememberMeToken(token) + + const tokens = await AuthUser.rememberMeTokens.all() + assert.lengthOf(tokens, 0) + }) +}) + +test.group('Session lucid user provider | rememberTokens | delete', () => { + test('delete token by series', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + await userProvider.createRememberMeToken(token) + await userProvider.deleteRememberMeTokenBySeries(token.series) + + const tokens = await AuthUser.rememberMeTokens.all() + assert.lengthOf(tokens, 0) + }) + + test('noop when no tokens exists in first place', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class AuthUser extends compose(User, withRememberMeTokens()) {} + + const userProvider = new SessionLucidUserProvider(getHasher(), { + model: async () => { + return { + default: AuthUser, + } + }, + uids: ['username', 'email'], + passwordColumnName: 'password', + }) + + await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) + const token = RememberMeToken.create(1, '20 mins', 'web') + await userProvider.createRememberMeToken(token) + await userProvider.deleteRememberMeTokenBySeries('foo') + + const tokens = await AuthUser.rememberMeTokens.all() + assert.lengthOf(tokens, 1) + }) +}) From 8f2028397d058ed3702231d4bf36f9757beec2be Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 12 Jan 2024 15:20:01 +0530 Subject: [PATCH 67/96] refactor: define session guard exports --- modules/session_guard/define_config.ts | 10 ++++++++++ modules/session_guard/guard.ts | 4 ++-- modules/session_guard/main.ts | 10 ++++++++++ .../{user_providers => providers}/lucid.ts | 0 package.json | 7 ++++++- tests/session/user_providers/lucid.spec.ts | 2 +- 6 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 modules/session_guard/define_config.ts create mode 100644 modules/session_guard/main.ts rename modules/session_guard/{user_providers => providers}/lucid.ts (100%) diff --git a/modules/session_guard/define_config.ts b/modules/session_guard/define_config.ts new file mode 100644 index 0000000..4acba72 --- /dev/null +++ b/modules/session_guard/define_config.ts @@ -0,0 +1,10 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export function sessionGuard() {} diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts index a2bd50f..797d2bd 100644 --- a/modules/session_guard/guard.ts +++ b/modules/session_guard/guard.ts @@ -8,7 +8,7 @@ */ import type { HttpContext } from '@adonisjs/core/http' -import { Exception, RuntimeException } from '@adonisjs/core/exceptions' +import { RuntimeException } from '@adonisjs/core/exceptions' import type { EmitterLike } from '@adonisjs/core/types/events' import debug from './debug.js' @@ -448,7 +448,7 @@ export class SessionGuard Date: Fri, 12 Jan 2024 22:44:51 +0530 Subject: [PATCH 68/96] refactor: simplify storage of remember me tokens --- bin/test.ts | 4 ++ factories/session_guard/main.ts | 2 +- modules/session_guard/guard.ts | 8 +-- .../session_guard/models/remember_me_token.ts | 13 ++-- modules/session_guard/providers/lucid.ts | 5 +- modules/session_guard/remember_me_token.ts | 54 ++++++--------- tests/helpers.ts | 2 - tests/session/guard/authenticate.spec.ts | 14 ++-- tests/session/guard/logout.spec.ts | 4 +- tests/session/remember_me_token.spec.ts | 68 ++++++++++++------- tests/session/user_providers/lucid.spec.ts | 52 ++++---------- 11 files changed, 99 insertions(+), 127 deletions(-) diff --git a/bin/test.ts b/bin/test.ts index 9f51d35..46a3234 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -11,6 +11,10 @@ configure({ name: 'session', files: ['tests/session/**/*.spec.ts'], }, + { + name: 'api_tokens', + files: ['tests/api_tokens/**/*.spec.ts'], + }, { name: 'auth', files: ['tests/auth/**/*.spec.ts'], diff --git a/factories/session_guard/main.ts b/factories/session_guard/main.ts index 62e8b49..3c41e19 100644 --- a/factories/session_guard/main.ts +++ b/factories/session_guard/main.ts @@ -114,7 +114,7 @@ export class SessionFakeUserProvider implements SessionUserProviderContract>(superclass: T) => { return class extends superclass { - static rememberMeTokens = RememberMeTokenModel + static rememberMeTokens = class extends RememberMeTokenModel { + static table = options?.table ?? 'remember_me_tokens' + } } } } diff --git a/modules/session_guard/providers/lucid.ts b/modules/session_guard/providers/lucid.ts index 7228e40..bda6596 100644 --- a/modules/session_guard/providers/lucid.ts +++ b/modules/session_guard/providers/lucid.ts @@ -247,10 +247,8 @@ export class SessionLucidUserProvider createdAt: token.createdAt, updatedAt: token.createdAt, expiresAt: token.expiresAt, - guard: token.guard, hash: token.hash, series: token.series, - type: token.type, }) } @@ -269,13 +267,12 @@ export class SessionLucidUserProvider createdAt: typeof token.createdAt === 'number' ? new Date(token.createdAt) : token.createdAt, updatedAt: typeof token.updatedAt === 'number' ? new Date(token.updatedAt) : token.updatedAt, expiresAt: typeof token.expiresAt === 'number' ? new Date(token.expiresAt) : token.expiresAt, - guard: token.guard, hash: token.hash, series: token.series, userId: token.userId, }) - if (rememberMeToken.isExpired() || token.type !== rememberMeToken.type) { + if (rememberMeToken.isExpired()) { return null } diff --git a/modules/session_guard/remember_me_token.ts b/modules/session_guard/remember_me_token.ts index d771d20..0eccccf 100644 --- a/modules/session_guard/remember_me_token.ts +++ b/modules/session_guard/remember_me_token.ts @@ -48,15 +48,7 @@ export class RememberMeToken { /** * Creates remember me token instance from persisted information. */ - static createFromPersisted(attributes: { - series: string - userId: string | number | BigInt - hash: string - guard: string - createdAt: Date - updatedAt: Date - expiresAt: Date - }) { + static createFromPersisted(attributes: ConstructorParameters[0]) { return new RememberMeToken(attributes) } @@ -65,14 +57,9 @@ export class RememberMeToken { * method computes the token series, value, hash and * timestamps */ - static create( - userId: string | number | BigInt, - expiry: string | number, - guard: string, - size: number = 30 - ) { + static create(userId: string | number | BigInt, expiry: string | number, size: number = 40) { const series = string.random(15) - const seed = string.random(size) + const seed = this.seed(size) const createdAt = new Date() const updatedAt = new Date() const expiresAt = new Date() @@ -81,8 +68,7 @@ export class RememberMeToken { const token = new RememberMeToken({ series, userId, - hash: createHash('sha256').update(seed).digest('hex'), - guard, + hash: RememberMeToken.hash(seed), createdAt, updatedAt, expiresAt, @@ -93,10 +79,20 @@ export class RememberMeToken { } /** - * Static name for the token to uniquely identify a - * bucket of tokens + * Generates hash for a value. Overwrite this method to customize + * hashing algo. */ - readonly type: 'remember_me_token' = 'remember_me_token' + static hash(value: string) { + return createHash('sha256').update(value).digest('hex') + } + + /** + * Creates a random string for an opaque token. You can override this + * method to customize token value generation + */ + static seed(size: number) { + return string.random(size) + } /** * Series is a unique sequence to identify the @@ -118,17 +114,11 @@ export class RememberMeToken { value?: Secret /** - * Hash is computed from the seed to later verify the validify + * Hash is computed from the seed to later verify the validity * of seed */ hash: string - /** - * Guard for which the token is generated. This is to avoid - * cross guards using each others remember me tokens - */ - guard: string - /** * Date/time when the token instance was created */ @@ -148,7 +138,6 @@ export class RememberMeToken { series: string userId: string | number | BigInt hash: string - guard: string createdAt: Date updatedAt: Date expiresAt: Date @@ -156,7 +145,6 @@ export class RememberMeToken { this.series = attributes.series this.userId = attributes.userId this.hash = attributes.hash - this.guard = attributes.guard this.createdAt = attributes.createdAt this.updatedAt = attributes.updatedAt this.expiresAt = attributes.expiresAt @@ -166,13 +154,13 @@ export class RememberMeToken { * Refreshes the token's value, hash, updatedAt and * expiresAt timestamps */ - refresh(expiry: string | number, size: number = 30) { - const seed = string.random(size) + refresh(expiry: string | number, size: number = 40) { + const seed = RememberMeToken.seed(size) /** * Re-computing public value and hash */ - this.hash = createHash('sha256').update(seed).digest('hex') + this.hash = RememberMeToken.hash(seed) this.value = new Secret(`${base64.urlEncode(this.series)}.${base64.urlEncode(seed)}`) /** diff --git a/tests/helpers.ts b/tests/helpers.ts index 81514b8..42cdb58 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -102,8 +102,6 @@ export async function createTables(db: Database) { await db.connection().schema.createTable('remember_me_tokens', (table) => { table.string('series', 60).notNullable() table.integer('user_id').notNullable().unsigned() - table.string('type').notNullable() - table.string('guard').notNullable() table.string('hash', 80).notNullable() table.datetime('created_at').notNullable() table.datetime('updated_at').notNullable() diff --git a/tests/session/guard/authenticate.spec.ts b/tests/session/guard/authenticate.spec.ts index 5679317..e1a1f7b 100644 --- a/tests/session/guard/authenticate.spec.ts +++ b/tests/session/guard/authenticate.spec.ts @@ -104,7 +104,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') const originalExpiryTime = token.expiresAt.getTime() const originalUpdatedAtTime = token.updatedAt.getTime() @@ -146,7 +146,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') const originalExpiryTime = token.expiresAt.getTime() const originalUpdatedAtTime = token.updatedAt.getTime() const originalHash = token.hash @@ -191,7 +191,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') userProvider.useToken(token) timeTravel(21 * 60) @@ -231,7 +231,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') ctx.request.request.headers.cookie = defineCookies([ { @@ -268,7 +268,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') userProvider.useToken(token) try { @@ -298,7 +298,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') userProvider.useToken(token) @@ -337,7 +337,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(10, '20 mins', 'web') + const token = RememberMeToken.create(10, '20 mins') userProvider.useToken(token) ctx.request.request.headers.cookie = defineCookies([ diff --git a/tests/session/guard/logout.spec.ts b/tests/session/guard/logout.spec.ts index b0434e6..b946c89 100644 --- a/tests/session/guard/logout.spec.ts +++ b/tests/session/guard/logout.spec.ts @@ -53,7 +53,7 @@ test.group('Session guard | logout', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') userProvider.useToken(token) ctx.request.request.headers.cookie = defineCookies([ @@ -93,7 +93,7 @@ test.group('Session guard | logout', () => { const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') userProvider.useToken(token) ctx.request.request.headers.cookie = defineCookies([ diff --git a/tests/session/remember_me_token.spec.ts b/tests/session/remember_me_token.spec.ts index fb5e151..ea3fe34 100644 --- a/tests/session/remember_me_token.spec.ts +++ b/tests/session/remember_me_token.spec.ts @@ -8,7 +8,6 @@ */ import { test } from '@japa/runner' -import { createHash } from 'node:crypto' import { setTimeout } from 'node:timers/promises' import { Secret, base64 } from '@adonisjs/core/helpers' @@ -22,21 +21,14 @@ test.group('Remember me token', () => { const expiresAt = new Date() expiresAt.setSeconds(date.getSeconds() + 60 * 20) - const token = RememberMeToken.create(1, '20mins', 'web') + const token = RememberMeToken.create(1, '20mins') assert.equal(token.userId, 1) assert.equal(token.createdAt.getTime(), date.getTime()) assert.equal(token.updatedAt.getTime(), date.getTime()) assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) assert.lengthOf(token.series, 15) assert.instanceOf(token.value, Secret) - assert.equal(token.guard, 'web') - assert.equal(token.type, 'remember_me_token') - assert.equal( - token.hash, - createHash('sha256') - .update(base64.urlDecode(token.value!.release().split('.')[1])!) - .digest('hex') - ) + assert.isTrue(token.verify(RememberMeToken.decode(token.value!.release())!.value)) assert.isFalse(token.isExpired()) }) @@ -52,15 +44,12 @@ test.group('Remember me token', () => { createdAt, updatedAt, expiresAt, - guard: 'web', series: '1', }) assert.equal(token.series, '1') assert.equal(token.hash, '1234') assert.equal(token.userId, 1) - assert.equal(token.guard, 'web') - assert.equal(token.type, 'remember_me_token') assert.equal(token.userId, 1) assert.equal(token.createdAt.getTime(), createdAt.getTime()) assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) @@ -81,7 +70,6 @@ test.group('Remember me token', () => { createdAt, updatedAt, expiresAt, - guard: 'web', series: '1', }) @@ -100,23 +88,57 @@ test.group('Remember me token', () => { const expiresAt = new Date() expiresAt.setSeconds(date.getSeconds() + 60 * 20) - const token = RememberMeToken.create(1, '20mins', 'web') + const token = RememberMeToken.create(1, '20mins') assert.isTrue(token.verify(base64.urlDecode(token.value!.release().split('.')[1])!)) }) test('decode remember me token', ({ assert }) => { - const token = RememberMeToken.create(1, '20mins', 'web') + const token = RememberMeToken.create(1, '20mins') const { series, value } = RememberMeToken.decode(token.value!.release())! assert.equal(series, token.series) assert.isTrue(token.verify(value)) }) - test('fail to decode invalid values', ({ assert }) => { - assert.isNull(RememberMeToken.decode(null as any)) - assert.isNull(RememberMeToken.decode('')) - assert.isNull(RememberMeToken.decode('...')) - assert.isNull(RememberMeToken.decode('foobar')) - assert.isNull(RememberMeToken.decode('foo.bar')) - }) + test('decode "{input}" as token') + .with([ + { + input: '', + output: null, + }, + { + input: '..', + output: null, + }, + { + input: 'foobar', + output: null, + }, + { + input: 'foo.bar', + output: null, + }, + { + input: 'baz.foo', + output: null, + }, + { + input: `bar.${base64.urlEncode('baz')}`, + output: null, + }, + { + input: `${base64.urlEncode('bar')}.baz`, + output: null, + }, + { + input: `${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`, + output: { + series: 'bar', + value: 'baz', + }, + }, + ]) + .run(({ assert }, { input, output }) => { + assert.deepEqual(RememberMeToken.decode(input), output) + }) }) diff --git a/tests/session/user_providers/lucid.spec.ts b/tests/session/user_providers/lucid.spec.ts index 53ca0f5..e26e5c3 100644 --- a/tests/session/user_providers/lucid.spec.ts +++ b/tests/session/user_providers/lucid.spec.ts @@ -8,13 +8,14 @@ */ import { test } from '@japa/runner' +import { compose } from '@poppinss/utils' import convertHrtime from 'convert-hrtime' import { BaseModel, column } from '@adonisjs/lucid/orm' + import { createDatabase, createTables, getHasher, timeTravel } from '../../helpers.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' import { SessionLucidUserProvider } from '../../../modules/session_guard/providers/lucid.js' -import { compose } from '@poppinss/utils' import { withRememberMeTokens } from '../../../modules/session_guard/models/remember_me_token.js' -import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' class User extends BaseModel { @column() @@ -395,7 +396,7 @@ test.group('Session lucid user provider | rememberTokens | create', () => { passwordColumnName: 'password', }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') await userProvider.createRememberMeToken(token) }).throws( 'Cannot perist remember me token using "User" model. Make sure to use "withRememberMeTokens" mixin' @@ -418,7 +419,7 @@ test.group('Session lucid user provider | rememberTokens | create', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') await userProvider.createRememberMeToken(token) const tokens = await AuthUser.rememberMeTokens.all() @@ -427,9 +428,7 @@ test.group('Session lucid user provider | rememberTokens | create', () => { createdAt: token.createdAt.getTime(), updatedAt: token.updatedAt.getTime(), expiresAt: token.expiresAt.getTime(), - type: token.type, series: token.series, - guard: token.guard, hash: token.hash, }) }) @@ -453,7 +452,7 @@ test.group('Session lucid user provider | rememberTokens | find', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') await userProvider.createRememberMeToken(token) const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) @@ -463,8 +462,6 @@ test.group('Session lucid user provider | rememberTokens | find', () => { assert.equal(rememberMeToken!.createdAt.getTime(), token.createdAt.getTime()) assert.equal(rememberMeToken!.hash, token.hash) assert.equal(rememberMeToken!.series, token.series) - assert.equal(rememberMeToken!.type, token.type) - assert.equal(rememberMeToken!.guard, token.guard) }) test('return null when token is missing', async ({ assert }) => { @@ -484,7 +481,7 @@ test.group('Session lucid user provider | rememberTokens | find', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) assert.isNull(rememberMeToken) @@ -507,7 +504,7 @@ test.group('Session lucid user provider | rememberTokens | find', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') await userProvider.createRememberMeToken(token) timeTravel(21 * 60) @@ -515,31 +512,6 @@ test.group('Session lucid user provider | rememberTokens | find', () => { const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) assert.isNull(rememberMeToken) }) - - test('return null when token type mismatches', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') - await userProvider.createRememberMeToken(token) - await AuthUser.rememberMeTokens.query().where('series', token.series).update({ type: 'foo' }) - - const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) - assert.isNull(rememberMeToken) - }) }) test.group('Session lucid user provider | rememberTokens | recycle', () => { @@ -560,7 +532,7 @@ test.group('Session lucid user provider | rememberTokens | recycle', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') const existingHash = token.hash const existingExpiresAt = token.expiresAt.getTime() const existingUpdateAt = token.updatedAt.getTime() @@ -596,7 +568,7 @@ test.group('Session lucid user provider | rememberTokens | recycle', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') token.refresh('30 mins') await userProvider.recycleRememberMeToken(token) @@ -623,7 +595,7 @@ test.group('Session lucid user provider | rememberTokens | delete', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') await userProvider.createRememberMeToken(token) await userProvider.deleteRememberMeTokenBySeries(token.series) @@ -648,7 +620,7 @@ test.group('Session lucid user provider | rememberTokens | delete', () => { }) await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins', 'web') + const token = RememberMeToken.create(1, '20 mins') await userProvider.createRememberMeToken(token) await userProvider.deleteRememberMeTokenBySeries('foo') From 8fbdb278873b9820ae777593ef7eb726371c2ef5 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sat, 13 Jan 2024 09:10:32 +0530 Subject: [PATCH 69/96] feat: implement authentication methods for access tokens guard --- bin/test.ts | 4 +- factories/access_token_guard/main.ts | 141 +++++++++++ modules/access_token_guard/access_token.ts | 175 +++++++++++++ modules/access_token_guard/crc32.ts | 101 ++++++++ modules/access_token_guard/debug.ts | 12 + modules/access_token_guard/guard.ts | 234 ++++++++++++++++++ modules/access_token_guard/types.ts | 82 ++++++ tests/access_token/access_token.spec.ts | 108 ++++++++ tests/access_token/guard/authenticate.spec.ts | 199 +++++++++++++++ tests/helpers.ts | 3 +- tests/session/guard/authenticate.spec.ts | 4 +- tests/session/remember_me_token.spec.ts | 6 +- 12 files changed, 1063 insertions(+), 6 deletions(-) create mode 100644 factories/access_token_guard/main.ts create mode 100644 modules/access_token_guard/access_token.ts create mode 100644 modules/access_token_guard/crc32.ts create mode 100644 modules/access_token_guard/debug.ts create mode 100644 modules/access_token_guard/guard.ts create mode 100644 modules/access_token_guard/types.ts create mode 100644 tests/access_token/access_token.spec.ts create mode 100644 tests/access_token/guard/authenticate.spec.ts diff --git a/bin/test.ts b/bin/test.ts index 46a3234..7775105 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -12,8 +12,8 @@ configure({ files: ['tests/session/**/*.spec.ts'], }, { - name: 'api_tokens', - files: ['tests/api_tokens/**/*.spec.ts'], + name: 'access_token', + files: ['tests/access_token/**/*.spec.ts'], }, { name: 'auth', diff --git a/factories/access_token_guard/main.ts b/factories/access_token_guard/main.ts new file mode 100644 index 0000000..7f2573c --- /dev/null +++ b/factories/access_token_guard/main.ts @@ -0,0 +1,141 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Secret } from '@adonisjs/core/helpers' +import stringHelpers from '@adonisjs/core/helpers/string' + +import { PROVIDER_REAL_USER } from '../../src/symbols.js' +import { AccessToken } from '../../modules/access_token_guard/access_token.js' +import { + GuardUser, + AccessTokenUserProviderContract, +} from '../../modules/access_token_guard/types.js' + +/** + * Representation of a fake user used to test + * the access token guard. + * + * @note + * Should not be exported to the outside world + */ +type AccessTokenFakeUser = { + id: number + email: string + password: string +} + +/** + * Collection of dummy users + */ +const users: AccessTokenFakeUser[] = [ + { + id: 1, + email: 'virk@adonisjs.com', + password: 'secret', + }, + { + id: 2, + email: 'romain@adonisjs.com', + password: 'secret', + }, +] + +/** + * Implementation of a user provider to be used by session guard for + * authentication. Used for testing. + * + * @note + * Should not be exported to the outside world + */ +export class AccessTokenFakeUserProvider + implements AccessTokenUserProviderContract +{ + declare [PROVIDER_REAL_USER]: AccessTokenFakeUser + #tokens: { + identifier: string + userId: number + hash: string + createdAt: Date + updatedAt: Date + expiresAt: Date + }[] = [] + + findUser(id: number) { + return users.find((user) => user.id === id) || null + } + + /** + * Creates an access token for a given user + */ + async createToken(user: AccessTokenFakeUser): Promise { + const accessToken = AccessToken.create(stringHelpers.random(15), '20 mins', 'oat_') + this.#tokens.push({ + identifier: accessToken.identifier, + userId: user.id, + hash: accessToken.hash, + createdAt: accessToken.createdAt, + updatedAt: accessToken.updatedAt, + expiresAt: accessToken.expiresAt, + }) + + return accessToken + } + + async deleteToken(token: Secret): Promise { + const decodedToken = AccessToken.decode('oat_', token!.release()) + if (!decodedToken) { + return + } + + this.#tokens = this.#tokens.filter(({ identifier }) => { + return identifier !== decodedToken.identifier + }) + } + + /** + * Returns a user for the given token + */ + async findUserByToken(token: Secret): Promise | null> { + const decodedToken = AccessToken.decode('oat_', token!.release()) + if (!decodedToken) { + return null + } + + const matchingToken = this.#tokens.find( + ({ identifier }) => identifier === decodedToken.identifier + ) + if (!matchingToken) { + return null + } + + const accessToken = new AccessToken({ + identifier: matchingToken.identifier, + hash: matchingToken.hash, + createdAt: matchingToken.createdAt, + updatedAt: matchingToken.updatedAt, + expiresAt: matchingToken.expiresAt, + }) + + if (accessToken.isExpired() || !accessToken.verify(decodedToken.seed)) { + return null + } + + const user = users.find(({ id }) => id === matchingToken.userId) + return user + ? { + getId() { + return user.id + }, + getOriginal() { + return user + }, + } + : null + } +} diff --git a/modules/access_token_guard/access_token.ts b/modules/access_token_guard/access_token.ts new file mode 100644 index 0000000..de7fa4c --- /dev/null +++ b/modules/access_token_guard/access_token.ts @@ -0,0 +1,175 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHash } from 'node:crypto' +import string from '@adonisjs/core/helpers/string' +import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' +import { CRC32 } from './crc32.js' + +/** + * Access token represents a token created for a user + * to authenticate using the auth module. + * + * It encapsulates the logic of creating an opaque token, generating + * its hash and verifying its hash. + */ +export class AccessToken { + /** + * Decodes a publicly shared token and return the series + * and the token value from it. + * + * Returns null when unable to decode the token because of + * invalid format or encoding. + */ + static decode(prefix: string, value: string): null | { identifier: string; seed: string } { + if (typeof value !== 'string' || !value.startsWith(`${prefix}`)) { + return null + } + + /** + * Remove prefix from the rest of the token. For example + * api_somerandomvalue will be converted to + * [api, somerandomvalue] + */ + const token = value.replace(new RegExp(`^${prefix}`), '') + if (!token) { + return null + } + + /** + * Split the token to read the identifier and the seed. + */ + const [identifier, ...seed] = token.split('.') + if (!identifier || seed.length === 0) { + return null + } + + /** + * Decode both the base64 encoded values + */ + const decodedIdentifer = base64.urlDecode(identifier) + const decodedSeed = base64.urlDecode(seed.join('.')) + if (!decodedIdentifer || !decodedSeed) { + return null + } + + return { + identifier: decodedIdentifer, + seed: decodedSeed, + } + } + + /** + * Creates a new access token instance. Calling this + * method computes the token value, hash and + * timestamps + */ + static create(identifier: string, expiry: string | number, prefix: string, size: number = 40) { + const seed = AccessToken.seed(size) + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + string.seconds.parse(expiry)) + + const token = new AccessToken({ + identifier, + hash: AccessToken.hash(seed), + createdAt, + updatedAt, + expiresAt, + }) + + token.value = new Secret(`${prefix}${base64.urlEncode(identifier)}.${base64.urlEncode(seed)}`) + return token + } + + /** + * Generates hash for a value. Overwrite this method to customize + * hashing algo. + */ + static hash(value: string) { + return createHash('sha256').update(value).digest('hex') + } + + /** + * Creates a random string for an opaque token. You can override this + * method to customize token value generation. + */ + static seed(size: number) { + const seed = string.random(size) + return `${seed}${new CRC32().calculate(seed)}` + } + + /** + * Identifier is a unique value that can be used + * to identify a token inside a persistance layer. + * + * The identifer should not have "." inside it. + */ + identifier: string + + /** + * Value is a combination of the "prefix""identifier"."seed" + * The value is shared with the user and later decoded to find and + * verify token validity + */ + value?: Secret + + /** + * Hash is computed from the seed to later verify the validify + * of seed + */ + hash: string + + /** + * Date/time when the token instance was created + */ + createdAt: Date + + /** + * Date/time when the token was updated + */ + updatedAt: Date + + /** + * Timestamp at which the token will expire + */ + expiresAt: Date + + constructor(attributes: { + identifier: string + hash: string + createdAt: Date + updatedAt: Date + expiresAt: Date + }) { + this.identifier = attributes.identifier + this.hash = attributes.hash + this.createdAt = attributes.createdAt + this.updatedAt = attributes.updatedAt + this.expiresAt = attributes.expiresAt + } + + /** + * Check if the token has been expired. Verifies + * the "expiresAt" timestamp with the current + * date. + */ + isExpired() { + return this.expiresAt < new Date() + } + + /** + * Verifies the value of a token against the pre-defined hash + */ + verify(value: string): boolean { + const newHash = createHash('sha256').update(value).digest('hex') + return safeEqual(this.hash, newHash) + } +} diff --git a/modules/access_token_guard/crc32.ts b/modules/access_token_guard/crc32.ts new file mode 100644 index 0000000..974184e --- /dev/null +++ b/modules/access_token_guard/crc32.ts @@ -0,0 +1,101 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * We use CRC32 just to add a recognizable checksum to tokens. This helps + * secret scanning tools like https://docs.github.com/en/github/administering-a-repository/about-secret-scanning easily detect tokens generated by a given program. + * + * You can learn more about appending checksum to a hash here in this Github + * article. https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ + * + * Code taken from: + * https://github.com/tsxper/crc32/blob/main/src/CRC32.ts + */ + +export class CRC32 { + /** + * Lookup table calculated for 0xEDB88320 divisor + */ + #lookupTable = [ + 0, 1996959894, 3993919788, 2567524794, 124634137, 1886057615, 3915621685, 2657392035, 249268274, + 2044508324, 3772115230, 2547177864, 162941995, 2125561021, 3887607047, 2428444049, 498536548, + 1789927666, 4089016648, 2227061214, 450548861, 1843258603, 4107580753, 2211677639, 325883990, + 1684777152, 4251122042, 2321926636, 335633487, 1661365465, 4195302755, 2366115317, 997073096, + 1281953886, 3579855332, 2724688242, 1006888145, 1258607687, 3524101629, 2768942443, 901097722, + 1119000684, 3686517206, 2898065728, 853044451, 1172266101, 3705015759, 2882616665, 651767980, + 1373503546, 3369554304, 3218104598, 565507253, 1454621731, 3485111705, 3099436303, 671266974, + 1594198024, 3322730930, 2970347812, 795835527, 1483230225, 3244367275, 3060149565, 1994146192, + 31158534, 2563907772, 4023717930, 1907459465, 112637215, 2680153253, 3904427059, 2013776290, + 251722036, 2517215374, 3775830040, 2137656763, 141376813, 2439277719, 3865271297, 1802195444, + 476864866, 2238001368, 4066508878, 1812370925, 453092731, 2181625025, 4111451223, 1706088902, + 314042704, 2344532202, 4240017532, 1658658271, 366619977, 2362670323, 4224994405, 1303535960, + 984961486, 2747007092, 3569037538, 1256170817, 1037604311, 2765210733, 3554079995, 1131014506, + 879679996, 2909243462, 3663771856, 1141124467, 855842277, 2852801631, 3708648649, 1342533948, + 654459306, 3188396048, 3373015174, 1466479909, 544179635, 3110523913, 3462522015, 1591671054, + 702138776, 2966460450, 3352799412, 1504918807, 783551873, 3082640443, 3233442989, 3988292384, + 2596254646, 62317068, 1957810842, 3939845945, 2647816111, 81470997, 1943803523, 3814918930, + 2489596804, 225274430, 2053790376, 3826175755, 2466906013, 167816743, 2097651377, 4027552580, + 2265490386, 503444072, 1762050814, 4150417245, 2154129355, 426522225, 1852507879, 4275313526, + 2312317920, 282753626, 1742555852, 4189708143, 2394877945, 397917763, 1622183637, 3604390888, + 2714866558, 953729732, 1340076626, 3518719985, 2797360999, 1068828381, 1219638859, 3624741850, + 2936675148, 906185462, 1090812512, 3747672003, 2825379669, 829329135, 1181335161, 3412177804, + 3160834842, 628085408, 1382605366, 3423369109, 3138078467, 570562233, 1426400815, 3317316542, + 2998733608, 733239954, 1555261956, 3268935591, 3050360625, 752459403, 1541320221, 2607071920, + 3965973030, 1969922972, 40735498, 2617837225, 3943577151, 1913087877, 83908371, 2512341634, + 3803740692, 2075208622, 213261112, 2463272603, 3855990285, 2094854071, 198958881, 2262029012, + 4057260610, 1759359992, 534414190, 2176718541, 4139329115, 1873836001, 414664567, 2282248934, + 4279200368, 1711684554, 285281116, 2405801727, 4167216745, 1634467795, 376229701, 2685067896, + 3608007406, 1308918612, 956543938, 2808555105, 3495958263, 1231636301, 1047427035, 2932959818, + 3654703836, 1088359270, 936918000, 2847714899, 3736837829, 1202900863, 817233897, 3183342108, + 3401237130, 1404277552, 615818150, 3134207493, 3453421203, 1423857449, 601450431, 3009837614, + 3294710456, 1567103746, 711928724, 3020668471, 3272380065, 1510334235, 755167117, + ] + + #initialCRC = 0xffffffff + + #calculateBytes(bytes: Uint8Array, accumulator?: number): number { + let crc = accumulator || this.#initialCRC + for (const byte of bytes) { + const tableIndex = (crc ^ byte) & 0xff + const tableVal = this.#lookupTable[tableIndex] as number + crc = (crc >>> 8) ^ tableVal + } + return crc + } + + #crcToUint(crc: number): number { + return this.#toUint32(crc ^ 0xffffffff) + } + + #strToBytes(input: string): Uint8Array { + const encoder = new TextEncoder() + return encoder.encode(input) + } + + #toUint32(num: number): number { + if (num >= 0) { + return num + } + return 0xffffffff - num * -1 + 1 + } + + calculate(input: string): number { + return this.forString(input) + } + + forString(input: string): number { + const bytes = this.#strToBytes(input) + return this.forBytes(bytes) + } + + forBytes(bytes: Uint8Array, accumulator?: number): number { + const crc = this.#calculateBytes(bytes, accumulator) + return this.#crcToUint(crc) + } +} diff --git a/modules/access_token_guard/debug.ts b/modules/access_token_guard/debug.ts new file mode 100644 index 0000000..c32f54d --- /dev/null +++ b/modules/access_token_guard/debug.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { debuglog } from 'node:util' + +export default debuglog('adonisjs:auth:access_token_guard') diff --git a/modules/access_token_guard/guard.ts b/modules/access_token_guard/guard.ts new file mode 100644 index 0000000..50e2073 --- /dev/null +++ b/modules/access_token_guard/guard.ts @@ -0,0 +1,234 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Secret } from '@adonisjs/core/helpers' +import type { HttpContext } from '@adonisjs/core/http' +import type { EmitterLike } from '@adonisjs/core/types/events' + +import debug from './debug.js' +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import type { AuthClientResponse, GuardContract } from '../../src/types.js' +import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' +import type { AccessTokenGuardEvents, AccessTokenUserProviderContract } from './types.js' + +/** + * Access token guard is used to authenticate incoming HTTP requests by + * reading the "Bearer" token from the Authorization header. + * + * The heavy lifting of verifying tokens and finding users is done by + * the userProvider. The guard job is integrate seamlessly with the + * auth layer of AdonisJS. + */ +export class AccessTokenGuard> + implements GuardContract +{ + /** + * Events emitted by the guard + */ + declare [GUARD_KNOWN_EVENTS]: AccessTokenGuardEvents + + /** + * A unique name for the guard. It is used for prefixing + * session data and remember me cookies + */ + #name: string + + /** + * Reference to the current HTTP context + */ + #ctx: HttpContext + + /** + * Emitter to emit events + */ + #emitter: EmitterLike> + + /** + * Provider to lookup user details + */ + #userProvider: UserProvider + + /** + * Driver name of the guard + */ + driverName: 'access_token' = 'access_token' + + /** + * Whether or not the authentication has been attempted + * during the current request. + */ + authenticationAttempted = false + + /** + * A boolean to know if the current request has + * been authenticated + */ + isAuthenticated = false + + /** + * Reference to an instance of the authenticated user. + * The value only exists after calling one of the + * following methods. + * + * - authenticate + * - check + * + * You can use the "getUserOrFail" method to throw an exception if + * the request is not authenticated. + */ + user?: UserProvider[typeof PROVIDER_REAL_USER] + + constructor( + name: string, + ctx: HttpContext, + emitter: EmitterLike>, + userProvider: UserProvider + ) { + this.#name = name + this.#ctx = ctx + this.#emitter = emitter + this.#userProvider = userProvider + debug('instantiating "%s" guard', this.#name) + } + + /** + * Emits authentication failure and returns an exception + * to end the authentication cycle. + */ + #authenticationFailed(token?: Secret) { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + + this.#emitter.emit('access_token_auth:authentication_failed', { + ctx: this.#ctx, + guardName: this.#name, + token, + error, + }) + + return error + } + + /** + * Returns an instance of the authenticated user. Or throws + * an exception if the request is not authenticated. + */ + getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { + if (!this.user) { + throw new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + } + + return this.user + } + + /** + * Authenticates the current HTTP request to contain a valid + * bearer token inside "Authorization" header. + * + * The token verification is performed by the registered user + * provider. + */ + async authenticate(): Promise { + /** + * Return early when authentication has already + * been attempted + */ + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + + debug('authenticating request to contain a valid bearer token') + + /** + * Notify we begin to attempt the authentication + */ + this.authenticationAttempted = true + this.#emitter.emit('access_token_auth:authentication_attempted', { + ctx: this.#ctx, + guardName: this.#name, + }) + + /** + * Ensure the authorization header exists and it contains a valid + * Bearer token. + */ + const bearerToken = this.#ctx.request.header('authorization', '')! + const [, token] = bearerToken.split('Bearer ') + if (!token) { + throw this.#authenticationFailed() + } + + debug('found bearer token in authorization header') + + /** + * Converting token to a secret and verify it using the provider. + * The provider must return a user instance if token is valid. + */ + const tokenAsSecret = new Secret(token) + const providerUser = await this.#userProvider.findUserByToken(tokenAsSecret) + if (!providerUser) { + throw this.#authenticationFailed(tokenAsSecret) + } + + debug('marking user with id "%s" as authenticated', providerUser.getId()) + + /** + * Update local state + */ + this.isAuthenticated = true + this.user = providerUser.getOriginal() + + /** + * Notify + */ + this.#emitter.emit('access_token_auth:authentication_succeeded', { + ctx: this.#ctx, + token: tokenAsSecret, + guardName: this.#name, + user: this.user, + }) + + return this.user + } + + /** + * Returns the Authorization header clients can use to authenticate + * the request. + */ + async authenticateAsClient( + user: UserProvider[typeof PROVIDER_REAL_USER] + ): Promise { + const token = await this.#userProvider.createToken(user) + return { + headers: { + Authorization: `Bearer ${token.value!.release()}`, + }, + } + } + + /** + * Silently check if the user is authenticated or not, without + * throwing any exceptions + */ + async check(): Promise { + try { + await this.authenticate() + return true + } catch (error) { + if (error instanceof E_UNAUTHORIZED_ACCESS) { + return false + } + + throw error + } + } +} diff --git a/modules/access_token_guard/types.ts b/modules/access_token_guard/types.ts new file mode 100644 index 0000000..2b8c3f4 --- /dev/null +++ b/modules/access_token_guard/types.ts @@ -0,0 +1,82 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Secret } from '@adonisjs/core/helpers' +import type { HttpContext } from '@adonisjs/core/http' +import type { Exception } from '@adonisjs/core/exceptions' + +import type { AccessToken } from './access_token.js' +import type { PROVIDER_REAL_USER } from '../../src/symbols.js' + +/** + * Guard user is an adapter between the user provider + * and the guard. + * + * The guard is user provider agnostic and therefore it + * needs a adapter to known some basic info about the + * user. + */ +export type GuardUser = { + getId(): string | number | BigInt + getOriginal(): RealUser +} + +/** + * User provider accepted by the Access token guard implementation + * to find users by tokens. + * + * The guards are responsible for decoding and verifying tokens + */ +export interface AccessTokenUserProviderContract { + [PROVIDER_REAL_USER]: RealUser + + /** + * Create a token for a given user. The return value must be an + * instance of an access token + */ + createToken(user: RealUser): Promise + + /** + * Find a user from a opaque token value + */ + findUserByToken(token: Secret): Promise | null> +} + +/** + * Events emitted by the access token guard + */ +export type AccessTokenGuardEvents = { + /** + * Attempting to authenticate the user + */ + 'access_token_auth:authentication_attempted': { + ctx: HttpContext + guardName: string + } + + /** + * Authentication was successful + */ + 'access_token_auth:authentication_succeeded': { + ctx: HttpContext + guardName: string + user: User + token: Secret + } + + /** + * Authentication failed + */ + 'access_token_auth:authentication_failed': { + ctx: HttpContext + guardName: string + error: Exception + token?: Secret + } +} diff --git a/tests/access_token/access_token.spec.ts b/tests/access_token/access_token.spec.ts new file mode 100644 index 0000000..58ee931 --- /dev/null +++ b/tests/access_token/access_token.spec.ts @@ -0,0 +1,108 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Secret, base64 } from '@poppinss/utils' + +import { timeTravel } from '../helpers.js' +import { AccessToken } from '../../modules/access_token_guard/access_token.js' + +test.group('AccessToken token | decode', () => { + test('decode "{input}" as token') + .with([ + { + input: null, + output: null, + }, + { + input: '', + output: null, + }, + { + input: '..', + output: null, + }, + { + input: 'foobar', + output: null, + }, + { + input: 'foo.baz', + output: null, + }, + { + input: 'foo_baz.foo', + output: null, + }, + { + input: `api_bar.${base64.urlEncode('baz')}`, + output: null, + }, + { + input: `api_${base64.urlEncode('baz')}.bar`, + output: null, + }, + { + input: `api_${base64.urlEncode('baz')}.${base64.urlEncode('baz')}`, + output: null, + }, + { + input: `auth_token_`, + output: null, + }, + { + input: `auth_token_..`, + output: null, + }, + { + input: `auth_token_foo.bar`, + output: null, + }, + { + input: `auth_token_${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`, + output: { + identifier: 'bar', + seed: 'baz', + }, + }, + ]) + .run(({ assert }, { input, output }) => { + assert.deepEqual(AccessToken.decode('auth_token_', input as string), output) + }) +}) + +test.group('AccessToken token | create', () => { + test('create new token', ({ assert }) => { + const token = AccessToken.create('1', '20mins', 'auth_tokens_') + + assert.exists(token.hash) + assert.exists(token.value) + assert.instanceOf(token.value, Secret) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.instanceOf(token.expiresAt, Date) + assert.isTrue(token.verify(AccessToken.decode('auth_tokens_', token.value!.release())!.seed)) + }) + + test('decode generated token', ({ assert }) => { + const token = AccessToken.create('1', '20mins', 'auth_tokens_') + const { seed, identifier } = AccessToken.decode('auth_tokens_', token.value!.release())! + + assert.equal(identifier, token.identifier) + assert.isTrue(token.verify(seed)) + }) + + test('check if token has been expired', ({ assert }) => { + const token = AccessToken.create('1', '20mins', 'auth_tokens') + assert.isFalse(token.isExpired()) + + timeTravel(21 * 60) + assert.isTrue(token.isExpired()) + }) +}) diff --git a/tests/access_token/guard/authenticate.spec.ts b/tests/access_token/guard/authenticate.spec.ts new file mode 100644 index 0000000..620b798 --- /dev/null +++ b/tests/access_token/guard/authenticate.spec.ts @@ -0,0 +1,199 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { createEmitter, timeTravel } from '../../helpers.js' +import { AccessTokenGuard } from '../../../modules/access_token_guard/guard.js' +import { AccessTokenFakeUserProvider } from '../../../factories/access_token_guard/main.js' + +test.group('Access token guard | authenticate', () => { + test('return user when access token is valid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + const token = await userProvider.createToken(userProvider.findUser(1)!) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const user = await guard.authenticate() + + assert.deepEqual(guard.user, user) + assert.deepEqual(guard.getUserOrFail(), user) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when no authorization header exists', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when authorization header does not have a bearer token', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + ctx.request.request.headers.authorization = 'foo bar' + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when authorization header has an empty bearer token', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + ctx.request.request.headers.authorization = 'Bearer ' + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when bearer token is invalid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + ctx.request.request.headers.authorization = 'Bearer helloworld' + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when bearer token does not exist', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + const token = await userProvider.createToken(userProvider.findUser(1)!) + await userProvider.deleteToken(token.value!) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when bearer token has been expired', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + const token = await userProvider.createToken(userProvider.findUser(1)!) + timeTravel(21 * 60) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('multiple calls to authenticate method should be a noop', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + const token = await userProvider.createToken(userProvider.findUser(1)!) + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + + /** + * Even though the token exists now, the authenticate + * method will use previous state + */ + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) +}) + +test.group('Access token guard | check', () => { + test('return true when access token is valid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + const token = await userProvider.createToken(userProvider.findUser(1)!) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const isLoggedIn = await guard.check() + + assert.isTrue(isLoggedIn) + assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('return false when access token is invalid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + const token = await userProvider.createToken(userProvider.findUser(1)!) + await userProvider.deleteToken(token.value!) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const isLoggedIn = await guard.check() + + assert.isFalse(isLoggedIn) + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) +}) + +test.group('Access token guard | authenticateAsClient', () => { + test('return bearer header for client', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokenFakeUserProvider() + + const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) + const user = userProvider.findUser(1)! + + const clientState = await guard.authenticateAsClient(user) + assert.property(clientState, 'headers') + assert.property(clientState.headers, 'Authorization') + assert.match(clientState.headers!.Authorization, /Bearer oat_[a-zA-z0-9]+\.[a-zA-z0-9]/) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 42cdb58..1162687 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -25,6 +25,7 @@ import { EncryptionFactory } from '@adonisjs/core/factories/encryption' import { SessionGuardEvents } from '../modules/session_guard/types.js' import { FactoryUser } from '../backup/factories/core/lucid_user_provider.js' +import { AccessTokenGuardEvents } from '../modules/access_token_guard/types.js' export const encryption: Encryption = new EncryptionFactory().create() @@ -120,7 +121,7 @@ export function createEmitter() { } const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) - return new Emitter>(app) + return new Emitter & AccessTokenGuardEvents>(app) } /** diff --git a/tests/session/guard/authenticate.spec.ts b/tests/session/guard/authenticate.spec.ts index e1a1f7b..a93c726 100644 --- a/tests/session/guard/authenticate.spec.ts +++ b/tests/session/guard/authenticate.spec.ts @@ -11,9 +11,9 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' import { SessionMiddlewareFactory } from '@adonisjs/session/factories' -import { createEmitter, defineCookies, timeTravel } from '../../helpers.js' import { E_UNAUTHORIZED_ACCESS } from '../../../src/errors.js' import { SessionGuard } from '../../../modules/session_guard/guard.js' +import { createEmitter, defineCookies, timeTravel } from '../../helpers.js' import { SessionFakeUserProvider } from '../../../factories/session_guard/main.js' import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' @@ -367,7 +367,7 @@ test.group('Session guard | authenticate | via remember me cookie', () => { }) }) -test.group('Session guard | authenticate | via session', () => { +test.group('Session guard | authenticate', () => { test('multiple calls to authenticate should be a noop', async ({ assert }) => { const ctx = new HttpContextFactory().create() const emitter = createEmitter() diff --git a/tests/session/remember_me_token.spec.ts b/tests/session/remember_me_token.spec.ts index ea3fe34..1e53a92 100644 --- a/tests/session/remember_me_token.spec.ts +++ b/tests/session/remember_me_token.spec.ts @@ -102,6 +102,10 @@ test.group('Remember me token', () => { test('decode "{input}" as token') .with([ + { + input: null, + output: null, + }, { input: '', output: null, @@ -139,6 +143,6 @@ test.group('Remember me token', () => { }, ]) .run(({ assert }, { input, output }) => { - assert.deepEqual(RememberMeToken.decode(input), output) + assert.deepEqual(RememberMeToken.decode(input as string), output) }) }) From e314f50d35df7d14dabc09d3ea638f26020d16ed Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sun, 14 Jan 2024 21:25:38 +0530 Subject: [PATCH 70/96] refactor: cleanup code as everything was mess in the momment Guards have been refactored a few times trying to find a sweet spot and ideal naming conventions. In this commit we remove guards temporirly. --- factories/access_token_guard/main.ts | 141 ---- factories/session_guard/main.ts | 137 ---- modules/access_token_guard/access_token.ts | 175 ----- modules/access_token_guard/crc32.ts | 101 --- modules/access_token_guard/debug.ts | 12 - modules/access_token_guard/guard.ts | 234 ------- modules/access_token_guard/types.ts | 82 --- modules/session_guard/debug.ts | 12 - modules/session_guard/define_config.ts | 10 - modules/session_guard/guard.ts | 633 ------------------ modules/session_guard/main.ts | 10 - .../session_guard/models/remember_me_token.ts | 50 -- modules/session_guard/providers/lucid.ts | 303 --------- modules/session_guard/remember_me_token.ts | 190 ------ modules/session_guard/types.ts | 230 ------- package.json | 17 +- src/authenticator.ts | 4 +- src/define_config.ts | 2 - src/errors.ts | 4 +- src/plugins/japa/browser_client.ts | 2 +- tests/access_token/access_token.spec.ts | 108 --- tests/access_token/guard/authenticate.spec.ts | 199 ------ tests/auth/define_config.spec.ts | 2 +- tests/helpers.ts | 32 +- tests/session/guard/authenticate.spec.ts | 521 -------------- tests/session/guard/login.spec.ts | 159 ----- tests/session/guard/logout.spec.ts | 128 ---- tests/session/remember_me_token.spec.ts | 148 ---- tests/session/user_providers/lucid.spec.ts | 630 ----------------- 29 files changed, 27 insertions(+), 4249 deletions(-) delete mode 100644 factories/access_token_guard/main.ts delete mode 100644 factories/session_guard/main.ts delete mode 100644 modules/access_token_guard/access_token.ts delete mode 100644 modules/access_token_guard/crc32.ts delete mode 100644 modules/access_token_guard/debug.ts delete mode 100644 modules/access_token_guard/guard.ts delete mode 100644 modules/access_token_guard/types.ts delete mode 100644 modules/session_guard/debug.ts delete mode 100644 modules/session_guard/define_config.ts delete mode 100644 modules/session_guard/guard.ts delete mode 100644 modules/session_guard/main.ts delete mode 100644 modules/session_guard/models/remember_me_token.ts delete mode 100644 modules/session_guard/providers/lucid.ts delete mode 100644 modules/session_guard/remember_me_token.ts delete mode 100644 modules/session_guard/types.ts delete mode 100644 tests/access_token/access_token.spec.ts delete mode 100644 tests/access_token/guard/authenticate.spec.ts delete mode 100644 tests/session/guard/authenticate.spec.ts delete mode 100644 tests/session/guard/login.spec.ts delete mode 100644 tests/session/guard/logout.spec.ts delete mode 100644 tests/session/remember_me_token.spec.ts delete mode 100644 tests/session/user_providers/lucid.spec.ts diff --git a/factories/access_token_guard/main.ts b/factories/access_token_guard/main.ts deleted file mode 100644 index 7f2573c..0000000 --- a/factories/access_token_guard/main.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Secret } from '@adonisjs/core/helpers' -import stringHelpers from '@adonisjs/core/helpers/string' - -import { PROVIDER_REAL_USER } from '../../src/symbols.js' -import { AccessToken } from '../../modules/access_token_guard/access_token.js' -import { - GuardUser, - AccessTokenUserProviderContract, -} from '../../modules/access_token_guard/types.js' - -/** - * Representation of a fake user used to test - * the access token guard. - * - * @note - * Should not be exported to the outside world - */ -type AccessTokenFakeUser = { - id: number - email: string - password: string -} - -/** - * Collection of dummy users - */ -const users: AccessTokenFakeUser[] = [ - { - id: 1, - email: 'virk@adonisjs.com', - password: 'secret', - }, - { - id: 2, - email: 'romain@adonisjs.com', - password: 'secret', - }, -] - -/** - * Implementation of a user provider to be used by session guard for - * authentication. Used for testing. - * - * @note - * Should not be exported to the outside world - */ -export class AccessTokenFakeUserProvider - implements AccessTokenUserProviderContract -{ - declare [PROVIDER_REAL_USER]: AccessTokenFakeUser - #tokens: { - identifier: string - userId: number - hash: string - createdAt: Date - updatedAt: Date - expiresAt: Date - }[] = [] - - findUser(id: number) { - return users.find((user) => user.id === id) || null - } - - /** - * Creates an access token for a given user - */ - async createToken(user: AccessTokenFakeUser): Promise { - const accessToken = AccessToken.create(stringHelpers.random(15), '20 mins', 'oat_') - this.#tokens.push({ - identifier: accessToken.identifier, - userId: user.id, - hash: accessToken.hash, - createdAt: accessToken.createdAt, - updatedAt: accessToken.updatedAt, - expiresAt: accessToken.expiresAt, - }) - - return accessToken - } - - async deleteToken(token: Secret): Promise { - const decodedToken = AccessToken.decode('oat_', token!.release()) - if (!decodedToken) { - return - } - - this.#tokens = this.#tokens.filter(({ identifier }) => { - return identifier !== decodedToken.identifier - }) - } - - /** - * Returns a user for the given token - */ - async findUserByToken(token: Secret): Promise | null> { - const decodedToken = AccessToken.decode('oat_', token!.release()) - if (!decodedToken) { - return null - } - - const matchingToken = this.#tokens.find( - ({ identifier }) => identifier === decodedToken.identifier - ) - if (!matchingToken) { - return null - } - - const accessToken = new AccessToken({ - identifier: matchingToken.identifier, - hash: matchingToken.hash, - createdAt: matchingToken.createdAt, - updatedAt: matchingToken.updatedAt, - expiresAt: matchingToken.expiresAt, - }) - - if (accessToken.isExpired() || !accessToken.verify(decodedToken.seed)) { - return null - } - - const user = users.find(({ id }) => id === matchingToken.userId) - return user - ? { - getId() { - return user.id - }, - getOriginal() { - return user - }, - } - : null - } -} diff --git a/factories/session_guard/main.ts b/factories/session_guard/main.ts deleted file mode 100644 index 3c41e19..0000000 --- a/factories/session_guard/main.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' -import { GuardUser, SessionUserProviderContract } from '../../modules/session_guard/types.js' -import { PROVIDER_REAL_USER } from '../../src/symbols.js' - -/** - * Representation of a fake user used to test - * the session guard. - * - * @note - * Should not be exported to the outside world - */ -type SessionFakeUser = { - id: number - email: string - password: string -} - -/** - * Collection of dummy users - */ -const users: SessionFakeUser[] = [ - { - id: 1, - email: 'virk@adonisjs.com', - password: 'secret', - }, - { - id: 2, - email: 'romain@adonisjs.com', - password: 'secret', - }, -] - -/** - * Implementation of a user provider to be used by session guard for - * authentication. Used for testing. - * - * @note - * Should not be exported to the outside world - */ -export class SessionFakeUserProvider implements SessionUserProviderContract { - declare [PROVIDER_REAL_USER]: SessionFakeUser - #token?: RememberMeToken - - /** - * Provide a token to use for "findRememberMeTokenBySeries" method. - */ - useToken(token: RememberMeToken) { - this.#token = token - } - - getToken() { - return this.#token - } - - async createUserForGuard(user: SessionFakeUser): Promise> { - return { - getId() { - return user.id - }, - getOriginal() { - return user - }, - } - } - - async findById(userId: string | number | BigInt): Promise | null> { - const user = users.find(({ id }) => id === userId) - if (!user) { - return null - } - - return this.createUserForGuard(user) - } - - async findByUid(uid: string | number): Promise | null> { - const user = users.find(({ email }) => email === uid) - if (!user) { - return null - } - - return this.createUserForGuard(user) - } - - async verifyCredentials( - uid: string | number, - password: string - ): Promise | null> { - const user = await this.findByUid(uid) - if (!user) { - return null - } - - if (user.getOriginal().password !== password) { - return null - } - - return user - } - - async findRememberMeTokenBySeries(series: string): Promise { - if (!this.#token) { - return null - } - if (this.#token.series !== series) { - return null - } - if (this.#token.isExpired()) { - return null - } - - return this.#token - } - - async recycleRememberMeToken(token: RememberMeToken): Promise { - this.#token = token - } - - async createRememberMeToken(token: RememberMeToken): Promise { - this.#token = token - } - - async deleteRememberMeTokenBySeries(series: string): Promise { - if (this.#token && this.#token.series === series) { - this.#token = undefined - } - } -} diff --git a/modules/access_token_guard/access_token.ts b/modules/access_token_guard/access_token.ts deleted file mode 100644 index de7fa4c..0000000 --- a/modules/access_token_guard/access_token.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { createHash } from 'node:crypto' -import string from '@adonisjs/core/helpers/string' -import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' -import { CRC32 } from './crc32.js' - -/** - * Access token represents a token created for a user - * to authenticate using the auth module. - * - * It encapsulates the logic of creating an opaque token, generating - * its hash and verifying its hash. - */ -export class AccessToken { - /** - * Decodes a publicly shared token and return the series - * and the token value from it. - * - * Returns null when unable to decode the token because of - * invalid format or encoding. - */ - static decode(prefix: string, value: string): null | { identifier: string; seed: string } { - if (typeof value !== 'string' || !value.startsWith(`${prefix}`)) { - return null - } - - /** - * Remove prefix from the rest of the token. For example - * api_somerandomvalue will be converted to - * [api, somerandomvalue] - */ - const token = value.replace(new RegExp(`^${prefix}`), '') - if (!token) { - return null - } - - /** - * Split the token to read the identifier and the seed. - */ - const [identifier, ...seed] = token.split('.') - if (!identifier || seed.length === 0) { - return null - } - - /** - * Decode both the base64 encoded values - */ - const decodedIdentifer = base64.urlDecode(identifier) - const decodedSeed = base64.urlDecode(seed.join('.')) - if (!decodedIdentifer || !decodedSeed) { - return null - } - - return { - identifier: decodedIdentifer, - seed: decodedSeed, - } - } - - /** - * Creates a new access token instance. Calling this - * method computes the token value, hash and - * timestamps - */ - static create(identifier: string, expiry: string | number, prefix: string, size: number = 40) { - const seed = AccessToken.seed(size) - const createdAt = new Date() - const updatedAt = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(createdAt.getSeconds() + string.seconds.parse(expiry)) - - const token = new AccessToken({ - identifier, - hash: AccessToken.hash(seed), - createdAt, - updatedAt, - expiresAt, - }) - - token.value = new Secret(`${prefix}${base64.urlEncode(identifier)}.${base64.urlEncode(seed)}`) - return token - } - - /** - * Generates hash for a value. Overwrite this method to customize - * hashing algo. - */ - static hash(value: string) { - return createHash('sha256').update(value).digest('hex') - } - - /** - * Creates a random string for an opaque token. You can override this - * method to customize token value generation. - */ - static seed(size: number) { - const seed = string.random(size) - return `${seed}${new CRC32().calculate(seed)}` - } - - /** - * Identifier is a unique value that can be used - * to identify a token inside a persistance layer. - * - * The identifer should not have "." inside it. - */ - identifier: string - - /** - * Value is a combination of the "prefix""identifier"."seed" - * The value is shared with the user and later decoded to find and - * verify token validity - */ - value?: Secret - - /** - * Hash is computed from the seed to later verify the validify - * of seed - */ - hash: string - - /** - * Date/time when the token instance was created - */ - createdAt: Date - - /** - * Date/time when the token was updated - */ - updatedAt: Date - - /** - * Timestamp at which the token will expire - */ - expiresAt: Date - - constructor(attributes: { - identifier: string - hash: string - createdAt: Date - updatedAt: Date - expiresAt: Date - }) { - this.identifier = attributes.identifier - this.hash = attributes.hash - this.createdAt = attributes.createdAt - this.updatedAt = attributes.updatedAt - this.expiresAt = attributes.expiresAt - } - - /** - * Check if the token has been expired. Verifies - * the "expiresAt" timestamp with the current - * date. - */ - isExpired() { - return this.expiresAt < new Date() - } - - /** - * Verifies the value of a token against the pre-defined hash - */ - verify(value: string): boolean { - const newHash = createHash('sha256').update(value).digest('hex') - return safeEqual(this.hash, newHash) - } -} diff --git a/modules/access_token_guard/crc32.ts b/modules/access_token_guard/crc32.ts deleted file mode 100644 index 974184e..0000000 --- a/modules/access_token_guard/crc32.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/** - * We use CRC32 just to add a recognizable checksum to tokens. This helps - * secret scanning tools like https://docs.github.com/en/github/administering-a-repository/about-secret-scanning easily detect tokens generated by a given program. - * - * You can learn more about appending checksum to a hash here in this Github - * article. https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ - * - * Code taken from: - * https://github.com/tsxper/crc32/blob/main/src/CRC32.ts - */ - -export class CRC32 { - /** - * Lookup table calculated for 0xEDB88320 divisor - */ - #lookupTable = [ - 0, 1996959894, 3993919788, 2567524794, 124634137, 1886057615, 3915621685, 2657392035, 249268274, - 2044508324, 3772115230, 2547177864, 162941995, 2125561021, 3887607047, 2428444049, 498536548, - 1789927666, 4089016648, 2227061214, 450548861, 1843258603, 4107580753, 2211677639, 325883990, - 1684777152, 4251122042, 2321926636, 335633487, 1661365465, 4195302755, 2366115317, 997073096, - 1281953886, 3579855332, 2724688242, 1006888145, 1258607687, 3524101629, 2768942443, 901097722, - 1119000684, 3686517206, 2898065728, 853044451, 1172266101, 3705015759, 2882616665, 651767980, - 1373503546, 3369554304, 3218104598, 565507253, 1454621731, 3485111705, 3099436303, 671266974, - 1594198024, 3322730930, 2970347812, 795835527, 1483230225, 3244367275, 3060149565, 1994146192, - 31158534, 2563907772, 4023717930, 1907459465, 112637215, 2680153253, 3904427059, 2013776290, - 251722036, 2517215374, 3775830040, 2137656763, 141376813, 2439277719, 3865271297, 1802195444, - 476864866, 2238001368, 4066508878, 1812370925, 453092731, 2181625025, 4111451223, 1706088902, - 314042704, 2344532202, 4240017532, 1658658271, 366619977, 2362670323, 4224994405, 1303535960, - 984961486, 2747007092, 3569037538, 1256170817, 1037604311, 2765210733, 3554079995, 1131014506, - 879679996, 2909243462, 3663771856, 1141124467, 855842277, 2852801631, 3708648649, 1342533948, - 654459306, 3188396048, 3373015174, 1466479909, 544179635, 3110523913, 3462522015, 1591671054, - 702138776, 2966460450, 3352799412, 1504918807, 783551873, 3082640443, 3233442989, 3988292384, - 2596254646, 62317068, 1957810842, 3939845945, 2647816111, 81470997, 1943803523, 3814918930, - 2489596804, 225274430, 2053790376, 3826175755, 2466906013, 167816743, 2097651377, 4027552580, - 2265490386, 503444072, 1762050814, 4150417245, 2154129355, 426522225, 1852507879, 4275313526, - 2312317920, 282753626, 1742555852, 4189708143, 2394877945, 397917763, 1622183637, 3604390888, - 2714866558, 953729732, 1340076626, 3518719985, 2797360999, 1068828381, 1219638859, 3624741850, - 2936675148, 906185462, 1090812512, 3747672003, 2825379669, 829329135, 1181335161, 3412177804, - 3160834842, 628085408, 1382605366, 3423369109, 3138078467, 570562233, 1426400815, 3317316542, - 2998733608, 733239954, 1555261956, 3268935591, 3050360625, 752459403, 1541320221, 2607071920, - 3965973030, 1969922972, 40735498, 2617837225, 3943577151, 1913087877, 83908371, 2512341634, - 3803740692, 2075208622, 213261112, 2463272603, 3855990285, 2094854071, 198958881, 2262029012, - 4057260610, 1759359992, 534414190, 2176718541, 4139329115, 1873836001, 414664567, 2282248934, - 4279200368, 1711684554, 285281116, 2405801727, 4167216745, 1634467795, 376229701, 2685067896, - 3608007406, 1308918612, 956543938, 2808555105, 3495958263, 1231636301, 1047427035, 2932959818, - 3654703836, 1088359270, 936918000, 2847714899, 3736837829, 1202900863, 817233897, 3183342108, - 3401237130, 1404277552, 615818150, 3134207493, 3453421203, 1423857449, 601450431, 3009837614, - 3294710456, 1567103746, 711928724, 3020668471, 3272380065, 1510334235, 755167117, - ] - - #initialCRC = 0xffffffff - - #calculateBytes(bytes: Uint8Array, accumulator?: number): number { - let crc = accumulator || this.#initialCRC - for (const byte of bytes) { - const tableIndex = (crc ^ byte) & 0xff - const tableVal = this.#lookupTable[tableIndex] as number - crc = (crc >>> 8) ^ tableVal - } - return crc - } - - #crcToUint(crc: number): number { - return this.#toUint32(crc ^ 0xffffffff) - } - - #strToBytes(input: string): Uint8Array { - const encoder = new TextEncoder() - return encoder.encode(input) - } - - #toUint32(num: number): number { - if (num >= 0) { - return num - } - return 0xffffffff - num * -1 + 1 - } - - calculate(input: string): number { - return this.forString(input) - } - - forString(input: string): number { - const bytes = this.#strToBytes(input) - return this.forBytes(bytes) - } - - forBytes(bytes: Uint8Array, accumulator?: number): number { - const crc = this.#calculateBytes(bytes, accumulator) - return this.#crcToUint(crc) - } -} diff --git a/modules/access_token_guard/debug.ts b/modules/access_token_guard/debug.ts deleted file mode 100644 index c32f54d..0000000 --- a/modules/access_token_guard/debug.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { debuglog } from 'node:util' - -export default debuglog('adonisjs:auth:access_token_guard') diff --git a/modules/access_token_guard/guard.ts b/modules/access_token_guard/guard.ts deleted file mode 100644 index 50e2073..0000000 --- a/modules/access_token_guard/guard.ts +++ /dev/null @@ -1,234 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Secret } from '@adonisjs/core/helpers' -import type { HttpContext } from '@adonisjs/core/http' -import type { EmitterLike } from '@adonisjs/core/types/events' - -import debug from './debug.js' -import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' -import type { AuthClientResponse, GuardContract } from '../../src/types.js' -import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' -import type { AccessTokenGuardEvents, AccessTokenUserProviderContract } from './types.js' - -/** - * Access token guard is used to authenticate incoming HTTP requests by - * reading the "Bearer" token from the Authorization header. - * - * The heavy lifting of verifying tokens and finding users is done by - * the userProvider. The guard job is integrate seamlessly with the - * auth layer of AdonisJS. - */ -export class AccessTokenGuard> - implements GuardContract -{ - /** - * Events emitted by the guard - */ - declare [GUARD_KNOWN_EVENTS]: AccessTokenGuardEvents - - /** - * A unique name for the guard. It is used for prefixing - * session data and remember me cookies - */ - #name: string - - /** - * Reference to the current HTTP context - */ - #ctx: HttpContext - - /** - * Emitter to emit events - */ - #emitter: EmitterLike> - - /** - * Provider to lookup user details - */ - #userProvider: UserProvider - - /** - * Driver name of the guard - */ - driverName: 'access_token' = 'access_token' - - /** - * Whether or not the authentication has been attempted - * during the current request. - */ - authenticationAttempted = false - - /** - * A boolean to know if the current request has - * been authenticated - */ - isAuthenticated = false - - /** - * Reference to an instance of the authenticated user. - * The value only exists after calling one of the - * following methods. - * - * - authenticate - * - check - * - * You can use the "getUserOrFail" method to throw an exception if - * the request is not authenticated. - */ - user?: UserProvider[typeof PROVIDER_REAL_USER] - - constructor( - name: string, - ctx: HttpContext, - emitter: EmitterLike>, - userProvider: UserProvider - ) { - this.#name = name - this.#ctx = ctx - this.#emitter = emitter - this.#userProvider = userProvider - debug('instantiating "%s" guard', this.#name) - } - - /** - * Emits authentication failure and returns an exception - * to end the authentication cycle. - */ - #authenticationFailed(token?: Secret) { - const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { - guardDriverName: this.driverName, - }) - - this.#emitter.emit('access_token_auth:authentication_failed', { - ctx: this.#ctx, - guardName: this.#name, - token, - error, - }) - - return error - } - - /** - * Returns an instance of the authenticated user. Or throws - * an exception if the request is not authenticated. - */ - getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { - if (!this.user) { - throw new E_UNAUTHORIZED_ACCESS('Unauthorized access', { - guardDriverName: this.driverName, - }) - } - - return this.user - } - - /** - * Authenticates the current HTTP request to contain a valid - * bearer token inside "Authorization" header. - * - * The token verification is performed by the registered user - * provider. - */ - async authenticate(): Promise { - /** - * Return early when authentication has already - * been attempted - */ - if (this.authenticationAttempted) { - return this.getUserOrFail() - } - - debug('authenticating request to contain a valid bearer token') - - /** - * Notify we begin to attempt the authentication - */ - this.authenticationAttempted = true - this.#emitter.emit('access_token_auth:authentication_attempted', { - ctx: this.#ctx, - guardName: this.#name, - }) - - /** - * Ensure the authorization header exists and it contains a valid - * Bearer token. - */ - const bearerToken = this.#ctx.request.header('authorization', '')! - const [, token] = bearerToken.split('Bearer ') - if (!token) { - throw this.#authenticationFailed() - } - - debug('found bearer token in authorization header') - - /** - * Converting token to a secret and verify it using the provider. - * The provider must return a user instance if token is valid. - */ - const tokenAsSecret = new Secret(token) - const providerUser = await this.#userProvider.findUserByToken(tokenAsSecret) - if (!providerUser) { - throw this.#authenticationFailed(tokenAsSecret) - } - - debug('marking user with id "%s" as authenticated', providerUser.getId()) - - /** - * Update local state - */ - this.isAuthenticated = true - this.user = providerUser.getOriginal() - - /** - * Notify - */ - this.#emitter.emit('access_token_auth:authentication_succeeded', { - ctx: this.#ctx, - token: tokenAsSecret, - guardName: this.#name, - user: this.user, - }) - - return this.user - } - - /** - * Returns the Authorization header clients can use to authenticate - * the request. - */ - async authenticateAsClient( - user: UserProvider[typeof PROVIDER_REAL_USER] - ): Promise { - const token = await this.#userProvider.createToken(user) - return { - headers: { - Authorization: `Bearer ${token.value!.release()}`, - }, - } - } - - /** - * Silently check if the user is authenticated or not, without - * throwing any exceptions - */ - async check(): Promise { - try { - await this.authenticate() - return true - } catch (error) { - if (error instanceof E_UNAUTHORIZED_ACCESS) { - return false - } - - throw error - } - } -} diff --git a/modules/access_token_guard/types.ts b/modules/access_token_guard/types.ts deleted file mode 100644 index 2b8c3f4..0000000 --- a/modules/access_token_guard/types.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { Secret } from '@adonisjs/core/helpers' -import type { HttpContext } from '@adonisjs/core/http' -import type { Exception } from '@adonisjs/core/exceptions' - -import type { AccessToken } from './access_token.js' -import type { PROVIDER_REAL_USER } from '../../src/symbols.js' - -/** - * Guard user is an adapter between the user provider - * and the guard. - * - * The guard is user provider agnostic and therefore it - * needs a adapter to known some basic info about the - * user. - */ -export type GuardUser = { - getId(): string | number | BigInt - getOriginal(): RealUser -} - -/** - * User provider accepted by the Access token guard implementation - * to find users by tokens. - * - * The guards are responsible for decoding and verifying tokens - */ -export interface AccessTokenUserProviderContract { - [PROVIDER_REAL_USER]: RealUser - - /** - * Create a token for a given user. The return value must be an - * instance of an access token - */ - createToken(user: RealUser): Promise - - /** - * Find a user from a opaque token value - */ - findUserByToken(token: Secret): Promise | null> -} - -/** - * Events emitted by the access token guard - */ -export type AccessTokenGuardEvents = { - /** - * Attempting to authenticate the user - */ - 'access_token_auth:authentication_attempted': { - ctx: HttpContext - guardName: string - } - - /** - * Authentication was successful - */ - 'access_token_auth:authentication_succeeded': { - ctx: HttpContext - guardName: string - user: User - token: Secret - } - - /** - * Authentication failed - */ - 'access_token_auth:authentication_failed': { - ctx: HttpContext - guardName: string - error: Exception - token?: Secret - } -} diff --git a/modules/session_guard/debug.ts b/modules/session_guard/debug.ts deleted file mode 100644 index 451511d..0000000 --- a/modules/session_guard/debug.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { debuglog } from 'node:util' - -export default debuglog('adonisjs:auth:session_guard') diff --git a/modules/session_guard/define_config.ts b/modules/session_guard/define_config.ts deleted file mode 100644 index 4acba72..0000000 --- a/modules/session_guard/define_config.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export function sessionGuard() {} diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts deleted file mode 100644 index bb403c6..0000000 --- a/modules/session_guard/guard.ts +++ /dev/null @@ -1,633 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import type { HttpContext } from '@adonisjs/core/http' -import { RuntimeException } from '@adonisjs/core/exceptions' -import type { EmitterLike } from '@adonisjs/core/types/events' - -import debug from './debug.js' -import { RememberMeToken } from './remember_me_token.js' -import type { AuthClientResponse, GuardContract } from '../../src/types.js' -import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' -import { E_INVALID_CREDENTIALS, E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' -import type { - SessionGuardConfig, - SessionGuardEvents, - SessionUserProviderContract, -} from './types.js' - -/** - * Session guard is an implementation of the AuthGuard contract to authenticate - * incoming HTTP requests using sessions. - * - * It also goes beyond to create login sessions for users and verify their - * credentials. - */ -export class SessionGuard> - implements GuardContract -{ - /** - * Events emitted by the guard - */ - declare [GUARD_KNOWN_EVENTS]: SessionGuardEvents - - /** - * A unique name for the guard. It is used for prefixing - * session data and remember me cookies - */ - #name: string - - /** - * Reference to the current HTTP context - */ - #ctx: HttpContext - - /** - * Configuration - */ - #config: SessionGuardConfig - - /** - * Provider to lookup user details - */ - #userProvider: UserProvider - - /** - * Emitter to emit events - */ - #emitter: EmitterLike> - - /** - * Driver name of the guard - */ - driverName: 'session' = 'session' - - /** - * Whether or not the authentication has been attempted - * during the current request. - */ - authenticationAttempted = false - - /** - * Find if the user has been logged out during - * the current request - */ - isLoggedOut = false - - /** - * A boolean to know if the current request has - * been authenticated - */ - isAuthenticated = false - - /** - * A boolean to know if the current request is authenticated - * using the "rememember_me" token. - */ - viaRemember = false - - /** - * Reference to an instance of the authenticated or logged-in - * user. The value only exists after calling one of the - * following methods. - * - * - login - * - loginViaId - * - attempt - * - authenticate - * - check - * - * You can use the "getUserOrFail" method to throw an exception if - * the request is not authenticated. - */ - user?: UserProvider[typeof PROVIDER_REAL_USER] - - /** - * The key used to store the logged-in user id inside - * session - */ - get sessionKeyName() { - return `auth_${this.#name}` - } - - /** - * The key used to store the remember me token cookie - */ - get rememberMeKeyName() { - return `remember_${this.#name}` - } - - constructor( - name: string, - config: SessionGuardConfig, - ctx: HttpContext, - emitter: EmitterLike>, - userProvider: UserProvider - ) { - this.#name = name - this.#ctx = ctx - this.#config = config - this.#emitter = emitter - this.#userProvider = userProvider - debug('instantiating "%s" guard, config %O', this.#name, this.#config) - } - - /** - * Returns the session instance for the given request, - * ensuring the property exists - */ - #getSession() { - if (!('session' in this.#ctx)) { - throw new RuntimeException( - 'Cannot authenticate user. Install and configure "@adonisjs/session" package' - ) - } - - return this.#ctx.session - } - - /** - * Notifies about login failure and returns an exception - * to end the login process. - */ - #loginFailed() { - const error = new E_INVALID_CREDENTIALS('Invalid user credentails', { - guardDriverName: this.driverName, - }) - - this.#emitter.emit('session_auth:login_failed', { - ctx: this.#ctx, - guardName: this.#name, - error, - }) - - return error - } - - /** - * Emits authentication failure and returns an exception - * to end the authentication cycle. - */ - #authenticationFailed(sessionId: string) { - const error = new E_UNAUTHORIZED_ACCESS('Invalid or expired user session', { - guardDriverName: this.driverName, - }) - - this.#emitter.emit('session_auth:authentication_failed', { - ctx: this.#ctx, - guardName: this.#name, - error, - sessionId, - }) - - return error - } - - /** - * Emits the authentication succeeded event and updates - * the local state to reflect successful authentication - */ - #authenticationSucceeded(sessionId: string, rememberMeToken?: RememberMeToken) { - this.isAuthenticated = true - this.isLoggedOut = false - this.viaRemember = !!rememberMeToken - - this.#emitter.emit('session_auth:authentication_succeeded', { - ctx: this.#ctx, - guardName: this.#name, - sessionId: sessionId, - user: this.user, - rememberMeToken, - }) - } - - /** - * Creates session for a given user by their user id. - */ - #createSessionForUser(userId: string | number | BigInt) { - const session = this.#getSession() - session.put(this.sessionKeyName, userId) - session.regenerate() - } - - /** - * Creates the remember me cookie - */ - #createRememberMeCookie(value: string) { - this.#ctx.response.encryptedCookie(this.rememberMeKeyName, value, { - maxAge: this.#config.rememberMeTokenAge || '2years', - httpOnly: true, - }) - } - - /** - * Recycles the remember me token by updating its timestamps - * and hash within the database. We ensure to only recycle - * when token is older than 1min from last update. - */ - async #recycleRememberMeToken(token: RememberMeToken, rememberMeCookie: string) { - /** - * Updated at with buffer represents the token's last updated - * at date + a buffer of 60 seconds to avoid race conditions - * where two concurrent requests recycles the token. - */ - const updatedAtWithBuffer = new Date(token.updatedAt) - updatedAtWithBuffer.setSeconds(updatedAtWithBuffer.getSeconds() + 60) - - if (new Date() > updatedAtWithBuffer) { - debug('recycling remember me token') - token.refresh(this.#config.rememberMeTokenAge || '2years') - await this.#userProvider.recycleRememberMeToken!(token) - this.#createRememberMeCookie(token.value!.release()) - } else { - this.#createRememberMeCookie(rememberMeCookie) - } - } - - /** - * Authenticates the user using its id read from the session - * store. - * - * - We check the user exists in the db - * - If not, throw exception. - * - Otherwise, update local state to mark the user as logged-in - */ - async #authenticateViaId(loggedInUserId: string | number | BigInt, sessionId: string) { - debug('authenticating user from session') - - /** - * Check the user exists with the provider - */ - const providerUser = await this.#userProvider.findById(loggedInUserId) - if (!providerUser) { - throw this.#authenticationFailed(sessionId) - } - - debug('marking user with id "%s" as authenticated', providerUser.getId()) - - this.user = providerUser.getOriginal() - this.#authenticationSucceeded(sessionId) - - return this.user - } - - /** - * Authenticates user from the remember me cookie. Creates a fresh - * session for them and recycles the remember me token as well. - */ - async #authenticateViaRememberCookie(rememberMeCookie: string, sessionId: string) { - debug('attempting to authenticate via rememberMeCookie') - - /** - * Fail authentication when user provider does not implement - * APIs needed to verify and recycle remember me tokens - */ - if ( - !this.#userProvider.findRememberMeTokenBySeries || - !this.#userProvider.recycleRememberMeToken - ) { - throw this.#authenticationFailed(sessionId) - } - - /** - * Decode token or fail when unable to do so - */ - const decodedToken = RememberMeToken.decode(rememberMeCookie) - if (!decodedToken) { - throw this.#authenticationFailed(sessionId) - } - - /** - * Search for token via provider and ensure token hash matches the - * token value and guard are the same. - * - * We expect the provider to check for expired tokens, return null for - * expired tokens and optionally delete them. - */ - const token = await this.#userProvider.findRememberMeTokenBySeries(decodedToken.series) - if (!token || !token.verify(decodedToken.value)) { - throw this.#authenticationFailed(sessionId) - } - - debug('found valid remember me token') - - /** - * Check if a user for the token exists. Otherwise abort - * authentication - */ - const providerUser = await this.#userProvider.findById(token.userId) - if (!providerUser) { - throw this.#authenticationFailed(sessionId) - } - - /** - * Create session - */ - const userId = providerUser.getId() - debug('marking user with id "%s" as logged in from remember me cookie', userId) - this.#createSessionForUser(userId) - - /** - * Emit event and update local state - */ - debug('marking user with id "%s" as authenticated', userId) - this.user = providerUser.getOriginal() - this.#authenticationSucceeded(sessionId, token) - - await this.#recycleRememberMeToken(token, rememberMeCookie) - return this.user - } - - /** - * Returns an instance of the authenticated user. Or throws - * an exception if the request is not authenticated. - */ - getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { - if (!this.user) { - throw new E_UNAUTHORIZED_ACCESS('Invalid or expired user session', { - guardDriverName: this.driverName, - }) - } - - return this.user - } - - /** - * Verifies user credentials and returns the user instance. - * Under the uses the provider to find the user and - * verify password. - */ - async verifyCredentials(uid: string, password: string) { - debug('attempting to verify credentials for uid "%s"', uid) - - /** - * Attempt to verify credentials and raise error if they - * are invalid - */ - const providerUser = await this.#userProvider.verifyCredentials(uid, password) - if (!providerUser) { - throw this.#loginFailed() - } - - const user = providerUser.getOriginal() - - /** - * Notify credentials have been verified - */ - this.#emitter.emit('session_auth:credentials_verified', { - ctx: this.#ctx, - guardName: this.#name, - uid, - user, - }) - - return user - } - - /** - * Attempt to login a user after verifying their - * credentials. - */ - async attempt( - uid: string, - password: string, - remember?: boolean - ): Promise { - const user = await this.verifyCredentials(uid, password) - return this.login(user, remember) - } - - /** - * Login a user by user id. Queries the user using the user provider - * and calls "login" method under the hood. - */ - async loginViaId(userId: string | number | BigInt, remember: boolean = false) { - debug('attempting to login user via id "%s"', userId) - - const providerUser = await this.#userProvider.findById(userId) - if (!providerUser) { - throw this.#loginFailed() - } - - return this.login(providerUser.getOriginal(), remember) - } - - /** - * Login a user by setting the session state. Optionally you - * can create the remember me cookie to have persistent - * login even after the session expires. - */ - async login( - user: UserProvider[typeof PROVIDER_REAL_USER], - remember: boolean = false - ): Promise { - this.#emitter.emit('session_auth:login_attempted', { - ctx: this.#ctx, - user, - guardName: this.#name, - }) - - /** - * Creating the provider user we can use to pull the - * user id - */ - const session = this.#getSession() - const providerUser = await this.#userProvider.createUserForGuard(user) - const userId = providerUser.getId() - - /** - * Create remember me token and persist it with the provider - * when remember me token is true. - */ - let token: RememberMeToken | undefined - if (remember) { - if (!this.#userProvider.createRememberMeToken) { - throw new RuntimeException( - 'Cannot use "rememberMe" feature. The provider does not implement the "createRememberMeToken" method' - ) - } - - debug('creating remember me cookie') - token = RememberMeToken.create(userId, this.#config.rememberMeTokenAge || '2years') - await this.#userProvider.createRememberMeToken(token) - this.#createRememberMeCookie(token.value!.release()) - } else { - this.#ctx.response.clearCookie(this.rememberMeKeyName) - } - - /** - * Create session - */ - debug('marking user with id "%s" as logged-in', userId) - this.#createSessionForUser(userId) - - /** - * Update local state - */ - this.user = user - this.isLoggedOut = false - - /** - * Notify login succeeded - */ - this.#emitter.emit('session_auth:login_succeeded', { - ctx: this.#ctx, - guardName: this.#name, - user, - sessionId: session.sessionId, - }) - - return this.user - } - - /** - * Authenticates the current HTTP request by reading the userId - * from the session and/or using the remember me token to have - * persistent login. - * - * Calling this method multiple times results in a noop. - */ - async authenticate(): Promise { - /** - * Return early when authentication has already - * been attempted - */ - if (this.authenticationAttempted) { - return this.getUserOrFail() - } - - this.authenticationAttempted = true - const session = this.#getSession() - - /** - * Notify we are starting the authentication process - */ - this.#emitter.emit('session_auth:authentication_attempted', { - ctx: this.#ctx, - guardName: this.#name, - sessionId: session.sessionId, - }) - - /** - * Check if there is a user id inside the session store. - * If yes, fetch the user from the persistent storage - * and mark them as logged-in - */ - const loggedInUserId = session.get(this.sessionKeyName) - if (loggedInUserId) { - return this.#authenticateViaId(loggedInUserId, session.sessionId) - } - - /** - * If rememberMeCookie exists then attempt to authenticate via the - * remember me cookie - */ - const rememberMeCookie = this.#ctx.request.encryptedCookie(this.rememberMeKeyName) - if (rememberMeCookie) { - return this.#authenticateViaRememberCookie(rememberMeCookie, session.sessionId) - } - - /** - * Otherwise fail - */ - throw this.#authenticationFailed(session.sessionId) - } - - /** - * Silently check if the user is authenticated or not, without - * throwing any exceptions - */ - async check(): Promise { - try { - await this.authenticate() - return true - } catch (error) { - if (error instanceof E_UNAUTHORIZED_ACCESS) { - return false - } - - throw error - } - } - - /** - * Logout user and revoke remember me token (if any) - */ - async logout() { - debug('session_auth: logging out') - const session = this.#getSession() - const rememberMeCookie = this.#ctx.request.encryptedCookie(this.rememberMeKeyName) - - /** - * Clear client side state - */ - session.forget(this.sessionKeyName) - this.#ctx.response.clearCookie(this.rememberMeKeyName) - - /** - * Update local state - */ - this.user = undefined - this.viaRemember = false - this.isAuthenticated = false - this.isLoggedOut = true - - /** - * Notify the user has been logged out - */ - this.#emitter.emit('session_auth:logged_out', { - ctx: this.#ctx, - guardName: this.#name, - user: this.user || null, - sessionId: session.sessionId, - }) - - /** - * Return early when there is no remember me cookie - * or if the provider does not implement the method - * to delete tokens - */ - if (!rememberMeCookie || !this.#userProvider.deleteRememberMeTokenBySeries) { - return - } - - /** - * Return early when remember me token is invalid - */ - debug('session_auth: decoding remember me token') - const decodedToken = RememberMeToken.decode(rememberMeCookie) - if (!decodedToken) { - return - } - - /** - * Delete the remember me token - */ - debug('session_auth: deleting remember me token') - await this.#userProvider.deleteRememberMeTokenBySeries(decodedToken.series) - } - - /** - * Returns the session info for the clients to send during - * an HTTP request to mark the user as logged-in. - */ - async authenticateAsClient( - user: UserProvider[typeof PROVIDER_REAL_USER] - ): Promise { - const providerUser = await this.#userProvider.createUserForGuard(user) - const userId = providerUser.getId() - - debug('session_guard: returning client session for user id "%s"', userId) - return { - session: { - [this.sessionKeyName]: userId, - }, - } - } -} diff --git a/modules/session_guard/main.ts b/modules/session_guard/main.ts deleted file mode 100644 index 2e842c6..0000000 --- a/modules/session_guard/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -export { RememberMeToken } from './remember_me_token.js' diff --git a/modules/session_guard/models/remember_me_token.ts b/modules/session_guard/models/remember_me_token.ts deleted file mode 100644 index a015d9b..0000000 --- a/modules/session_guard/models/remember_me_token.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { BaseModel, column } from '@adonisjs/lucid/orm' -import { NormalizeConstructor } from '@adonisjs/core/types/helpers' - -export class RememberMeTokenModel extends BaseModel { - /** - * The series property is the primary key - */ - static selfAssignPrimaryKey = true - - @column({ isPrimary: true }) - declare series: string - - @column() - declare userId: number | string | BigInt - - @column() - declare hash: string - - @column() - declare createdAt: Date - - @column() - declare updatedAt: Date - - @column() - declare expiresAt: Date -} - -/** - * Mixin to add support for remember me tokens on a - * user model - */ -export function withRememberMeTokens(options?: { table?: string }) { - return >(superclass: T) => { - return class extends superclass { - static rememberMeTokens = class extends RememberMeTokenModel { - static table = options?.table ?? 'remember_me_tokens' - } - } - } -} diff --git a/modules/session_guard/providers/lucid.ts b/modules/session_guard/providers/lucid.ts deleted file mode 100644 index bda6596..0000000 --- a/modules/session_guard/providers/lucid.ts +++ /dev/null @@ -1,303 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Hash } from '@adonisjs/core/hash' -import { RuntimeException } from '@poppinss/utils' - -import debug from '../debug.js' -import { RememberMeToken } from '../remember_me_token.js' -import { PROVIDER_REAL_USER } from '../../../src/symbols.js' -import type { - GuardUser, - LucidAuthenticatable, - SessionUserProviderContract, - SessionLucidUserProviderOptions, -} from '../types.js' - -/** - * Lucid user represents a guard user, used by authentication guards - * to perform authentication. - */ -class LucidUser> - implements GuardUser -{ - constructor(public realUser: RealUser) {} - - /** - * @inheritdoc - */ - getId(): string | number { - const id = this.realUser.$primaryKeyValue - - /** - * Ensure id exists - */ - if (!id) { - const model = this.realUser.constructor as LucidAuthenticatable - const modelName = model.name - const primaryKey = model.primaryKey - throw new RuntimeException( - `Cannot use "${modelName}" model for authentication. The value of column "${primaryKey}" is undefined or null` - ) - } - - return id - } - - /** - * Returns the original user by reference - */ - getOriginal(): RealUser { - return this.realUser - } -} - -/** - * Implementation of session user provider that uses lucid models - * to find user and remember me tokens - */ -export class SessionLucidUserProvider - implements SessionUserProviderContract> -{ - declare [PROVIDER_REAL_USER]: InstanceType - - /** - * Reference to the lazily imported model - */ - protected model?: UserModel - - constructor( - /** - * Hasher is used to verify plain text passwords - */ - protected hasher: Hash, - - /** - * Lucid provider options - */ - protected options: SessionLucidUserProviderOptions - ) {} - - /** - * Returns the remember me model associated with - * user model - */ - protected async getRememberMeModel() { - const model = await this.getModel() - if (!model.rememberMeTokens) { - throw new RuntimeException( - `Cannot perist remember me token using "${model.name}" model. Make sure to use "withRememberMeTokens" mixin` - ) - } - - return model.rememberMeTokens - } - - /** - * Imports the model from the provider, returns and caches it - * for further operations. - */ - protected async getModel() { - if (this.model) { - return this.model - } - - const importedModel = await this.options.model() - this.model = importedModel.default - debug('lucid_user_provider: using model [class %s]', this.model.name) - return this.model - } - - /** - * Returns an instance of the query builder - */ - protected getQueryBuilder(model: UserModel) { - return model.query({ - connection: this.options.connection, - }) - } - - /** - * Returns an instance of the "LucidUser" that guards - * can use for authentication - */ - async createUserForGuard(user: InstanceType) { - const model = await this.getModel() - if (user instanceof model === false) { - throw new RuntimeException( - `Invalid user object. It must be an instance of the "${model.name}" model` - ) - } - - debug('lucid_user_provider: converting user object to guard user %O', user) - return new LucidUser(user) - } - - /** - * Finds a user by id using the configured model. - */ - async findById( - value: string | number | BigInt - ): Promise> | null> { - debug('lucid_user_provider: finding user by id %s', value) - - const model = await this.getModel() - const user = await model.find(value, { - connection: this.options.connection, - }) - - if (!user) { - return null - } - - return this.createUserForGuard(user) - } - - /** - * Finds the user by uid and returns an instance of the guard user - */ - async findByUid(uid: string | number): Promise> | null> { - const model = await this.getModel() - - /** - * Use custom lookup method when defined on the model. - */ - if (typeof model.getUserForAuth === 'function') { - debug('lucid_user_provider: using getUserForAuth method on "[class %s]"', model.name) - - const user = await model.getUserForAuth(this.options.uids, uid) - if (!user) { - return null - } - - return this.createUserForGuard(user) - } - - /** - * Self query - */ - debug('lucid_user_provider: finding user by uids: %O, value: %s', this.options.uids, uid) - const query = this.getQueryBuilder(model) - this.options.uids.forEach((uidColumn) => query.orWhere(uidColumn, uid)) - - const user = await query.limit(1).first() - if (!user) { - return null - } - - return this.createUserForGuard(user) - } - - /** - * Find a user by uid and verify their password. This method prevents - * timing attacks. - */ - async verifyCredentials( - uid: string | number, - password: string - ): Promise> | null> { - const user = await this.findByUid(uid) - - /** - * Hashing the password to prevent timing attacks. - */ - if (!user) { - await this.hasher.make(password) - return null - } - - /** - * Check the password hash exists on the model or thrown - * an error - */ - const passwordHash = user.getOriginal()[this.options.passwordColumnName] - if (!passwordHash) { - throw new RuntimeException( - `Cannot verify password during login. The value of column "${this.options.passwordColumnName}" is undefined or null` - ) - } - - /** - * Verify password - */ - if (await this.hasher.verify(passwordHash as string, password)) { - return user - } - - /** - * Invalid password, return null - */ - return null - } - - /** - * Persists the remember token to the database using the - * model.rememberMeTokens property - */ - async createRememberMeToken(token: RememberMeToken): Promise { - const rememberMeModel = await this.getRememberMeModel() - await rememberMeModel.create({ - userId: token.userId, - createdAt: token.createdAt, - updatedAt: token.createdAt, - expiresAt: token.expiresAt, - hash: token.hash, - series: token.series, - }) - } - - /** - * Finds a remember me token for a user by the series. - * Uses model.rememberMeTokens property - */ - async findRememberMeTokenBySeries(series: string): Promise { - const rememberMeModel = await this.getRememberMeModel() - const token = await rememberMeModel.query().where('series', series).limit(1).first() - if (!token) { - return null - } - - const rememberMeToken = RememberMeToken.createFromPersisted({ - createdAt: typeof token.createdAt === 'number' ? new Date(token.createdAt) : token.createdAt, - updatedAt: typeof token.updatedAt === 'number' ? new Date(token.updatedAt) : token.updatedAt, - expiresAt: typeof token.expiresAt === 'number' ? new Date(token.expiresAt) : token.expiresAt, - hash: token.hash, - series: token.series, - userId: token.userId, - }) - - if (rememberMeToken.isExpired()) { - return null - } - - return rememberMeToken - } - - /** - * Updates the remember me token with new attributes. Uses - * model.rememberMeTokens property - */ - async recycleRememberMeToken(token: RememberMeToken): Promise { - const rememberMeModel = await this.getRememberMeModel() - await rememberMeModel.query().where('series', token.series).update({ - hash: token.hash, - updatedAt: token.updatedAt, - expiresAt: token.expiresAt, - }) - } - - /** - * Deletes an existing remember me token. Uses model.rememberMeTokens - * property. - */ - async deleteRememberMeTokenBySeries(series: string): Promise { - const rememberMeModel = await this.getRememberMeModel() - await rememberMeModel.query().where('series', series).del() - } -} diff --git a/modules/session_guard/remember_me_token.ts b/modules/session_guard/remember_me_token.ts deleted file mode 100644 index 0eccccf..0000000 --- a/modules/session_guard/remember_me_token.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { createHash } from 'node:crypto' -import string from '@adonisjs/core/helpers/string' -import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' - -/** - * Remember me token represents a remember me token created - * for a peristed login flow. - */ -export class RememberMeToken { - /** - * Decodes a publicly shared token and return the series - * and the token value from it. - * - * Returns null when unable to decode the token because of - * invalid format or encoding. - */ - static decode(value: string): null | { series: string; value: string } { - if (typeof value !== 'string') { - return null - } - - const [series, ...tokenValue] = value.split('.') - if (!series || tokenValue.length === 0) { - return null - } - - const decodedSeries = base64.urlDecode(series) - const decodedValue = base64.urlDecode(tokenValue.join('.')) - if (!decodedSeries || !decodedValue) { - return null - } - - return { - series: decodedSeries, - value: decodedValue, - } - } - - /** - * Creates remember me token instance from persisted information. - */ - static createFromPersisted(attributes: ConstructorParameters[0]) { - return new RememberMeToken(attributes) - } - - /** - * Creates a new remember me token instance. Calling this - * method computes the token series, value, hash and - * timestamps - */ - static create(userId: string | number | BigInt, expiry: string | number, size: number = 40) { - const series = string.random(15) - const seed = this.seed(size) - const createdAt = new Date() - const updatedAt = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(createdAt.getSeconds() + string.seconds.parse(expiry)) - - const token = new RememberMeToken({ - series, - userId, - hash: RememberMeToken.hash(seed), - createdAt, - updatedAt, - expiresAt, - }) - - token.value = new Secret(`${base64.urlEncode(token.series)}.${base64.urlEncode(seed)}`) - return token - } - - /** - * Generates hash for a value. Overwrite this method to customize - * hashing algo. - */ - static hash(value: string) { - return createHash('sha256').update(value).digest('hex') - } - - /** - * Creates a random string for an opaque token. You can override this - * method to customize token value generation - */ - static seed(size: number) { - return string.random(size) - } - - /** - * Series is a unique sequence to identify the - * token within database. It should be the - * primary/unique key - */ - series: string - - /** - * Reference to the user id for whom the token - * is generated - */ - userId: string | number | BigInt - - /** - * The series and seed is persisted inside the cookie and later - * splitted to perform the lookup. - */ - value?: Secret - - /** - * Hash is computed from the seed to later verify the validity - * of seed - */ - hash: string - - /** - * Date/time when the token instance was created - */ - createdAt: Date - - /** - * Date/time when the token was updated - */ - updatedAt: Date - - /** - * Timestamp at which the token will expire - */ - expiresAt: Date - - constructor(attributes: { - series: string - userId: string | number | BigInt - hash: string - createdAt: Date - updatedAt: Date - expiresAt: Date - }) { - this.series = attributes.series - this.userId = attributes.userId - this.hash = attributes.hash - this.createdAt = attributes.createdAt - this.updatedAt = attributes.updatedAt - this.expiresAt = attributes.expiresAt - } - - /** - * Refreshes the token's value, hash, updatedAt and - * expiresAt timestamps - */ - refresh(expiry: string | number, size: number = 40) { - const seed = RememberMeToken.seed(size) - - /** - * Re-computing public value and hash - */ - this.hash = RememberMeToken.hash(seed) - this.value = new Secret(`${base64.urlEncode(this.series)}.${base64.urlEncode(seed)}`) - - /** - * Updating expiry and updated_at timestamp - */ - this.updatedAt = new Date() - this.expiresAt = new Date() - this.expiresAt.setSeconds(this.updatedAt.getSeconds() + string.seconds.parse(expiry)) - } - - /** - * Check if the token has been expired. Verifies - * the "expiresAt" timestamp with the current - * date. - */ - isExpired() { - return this.expiresAt < new Date() - } - - /** - * Verifies the value of a token against the pre-defined hash - */ - verify(value: string): boolean { - const newHash = createHash('sha256').update(value).digest('hex') - return safeEqual(this.hash, newHash) - } -} diff --git a/modules/session_guard/types.ts b/modules/session_guard/types.ts deleted file mode 100644 index 39e8c8f..0000000 --- a/modules/session_guard/types.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { HashersList } from '@adonisjs/core/types' -import type { HttpContext } from '@adonisjs/core/http' -import { LucidModel } from '@adonisjs/lucid/types/model' -import type { Exception } from '@adonisjs/core/exceptions' -import type { RememberMeTokenModel } from './models/remember_me_token.js' - -import type { RememberMeToken } from './remember_me_token.js' -import type { PROVIDER_REAL_USER } from '../../src/symbols.js' - -/** - * Options accepted by the Session Lucid user provider - */ -export type SessionLucidUserProviderOptions = { - /** - * Define the hasher to use to hash and verify - * passwords - */ - hasher?: keyof HashersList - - /** - * Optionally define the connection to use when making database - * queries - */ - connection?: string - - /** - * Model to use for authentication - */ - model: () => Promise<{ default: Model }> - - /** - * Column name to read the hashed password - */ - passwordColumnName: Extract, string> - - /** - * An array of uids to use when finding a user for login. Make - * sure all fields can be used to uniquely lookup a user. - */ - uids: Extract, string>[] -} - -/** - * A lucid model that can be used during authentication - */ -export type LucidAuthenticatable = LucidModel & { - /** - * HasMany relationship to manage rememberMe tokens - */ - rememberMeTokens?: typeof RememberMeTokenModel - - /** - * Optional static method to customize the user lookup - * during "findByUid" method call. - */ - getUserForAuth?(uids: string[], value: string | number): Promise -} - -/** - * Guard user is an adapter between the user provider - * and the guard. - * - * The guard is user provider agnostic and therefore it - * needs a adapter to known some basic info about the - * user. - */ -export type GuardUser = { - getId(): string | number | BigInt - getOriginal(): RealUser -} - -/** - * The user provider used by the session guard to lookup - * user and persist remember me tokens - */ -export interface SessionUserProviderContract { - [PROVIDER_REAL_USER]: RealUser - - /** - * Creates a user object that guard can use for authentication - */ - createUserForGuard(user: RealUser): Promise> - - /** - * Find a user by uid. The uid could be one or multiple fields - * to unique identify a user. - * - * This method is called when finding a user for login - */ - findByUid(uid: string | number): Promise | null> - - /** - * Find a user by unique primary id. This method is called when - * authenticating user from their session. - */ - findById(userId: string | number | BigInt): Promise | null> - - /** - * Find a user by uid and verify their password. This method prevents - * timing attacks. - */ - verifyCredentials(uid: string | number, password: string): Promise | null> - - /** - * Persist remember me token. The userId is available via the token.userId property. - */ - createRememberMeToken?(token: RememberMeToken): Promise - - /** - * Delete the remember token by the series value - */ - deleteRememberMeTokenBySeries?(series: string): Promise - - /** - * Find a remember me token by the series value - */ - findRememberMeTokenBySeries?(series: string): Promise - - /** - * Recycle the remember me token attributes. The update must be - * performed by first finding the token by series and then - * updating its attributes. - */ - recycleRememberMeToken?(token: RememberMeToken): Promise -} - -/** - * Config accepted by the session guard - */ -export type SessionGuardConfig = { - rememberMeTokenAge?: number | string -} - -/** - * Events emitted by the session guard - */ -export type SessionGuardEvents = { - /** - * The event is emitted when the user credentials - * have been verified successfully. - */ - 'session_auth:credentials_verified': { - ctx: HttpContext - guardName: string - uid: string - user: User - } - - /** - * The event is emitted when unable to login the - * user. - */ - 'session_auth:login_failed': { - ctx: HttpContext - guardName: string - error: Exception - } - - /** - * The event is emitted when login is attempted for - * a given user. - */ - 'session_auth:login_attempted': { - ctx: HttpContext - guardName: string - user: User - } - - /** - * The event is emitted when user has been logged in - * successfully - */ - 'session_auth:login_succeeded': { - ctx: HttpContext - guardName: string - user: User - sessionId: string - rememberMeToken?: RememberMeToken - } - - /** - * Attempting to authenticate the user - */ - 'session_auth:authentication_attempted': { - ctx: HttpContext - guardName: string - sessionId: string - } - - /** - * Authentication was successful - */ - 'session_auth:authentication_succeeded': { - ctx: HttpContext - guardName: string - user: User - sessionId: string - rememberMeToken?: RememberMeToken - } - - /** - * Authentication failed - */ - 'session_auth:authentication_failed': { - ctx: HttpContext - guardName: string - error: Exception - sessionId: string - } - - /** - * The event is emitted when user has been logged out - * sucessfully - */ - 'session_auth:logged_out': { - ctx: HttpContext - guardName: string - user: User | null - sessionId: string - } -} diff --git a/package.json b/package.json index 78bc049..184ba77 100644 --- a/package.json +++ b/package.json @@ -21,16 +21,11 @@ "exports": { ".": "./build/index.js", "./types": "./build/src/types.js", - "./types/session": "./build/modules/session_guard/types.js", "./auth_provider": "./build/providers/auth_provider.js", "./plugins/api_client": "./build/src/plugins/japa/api_client.js", "./plugins/browser_client": "./build/src/plugins/japa/browser_client.js", "./services/main": "./build/services/auth.js", - "./initialize_auth_middleware": "./build/src/middleware/initialize_auth_middleware.js", - "./guards/session": "./build/modules/session_guard/main.js", - "./guards/session/guard": "./build/modules/session_guard/guard.js", - "./guards/session/models/*": "./build/modules/session_guard/models/*.js", - "./guards/session/providers/*": "./build/modules/session_guard/providers/*.js" + "./initialize_auth_middleware": "./build/src/middleware/initialize_auth_middleware.js" }, "scripts": { "pretest": "npm run lint", @@ -70,7 +65,6 @@ "@adonisjs/i18n": "^2.0.0", "@adonisjs/lucid": "^19.0.0", "@adonisjs/prettier-config": "^1.2.1", - "@adonisjs/redis": "^8.0.0", "@adonisjs/session": "^7.0.0", "@adonisjs/tsconfig": "^1.2.1", "@commitlint/cli": "^18.4.4", @@ -138,16 +132,15 @@ }, "dependencies": { "@adonisjs/presets": "^2.1.1", - "@poppinss/utils": "^6.7.0", "basic-auth": "^2.0.1" }, "peerDependencies": { - "@adonisjs/core": "^6.1.5-36", - "@adonisjs/lucid": "^19.0.0-8", - "@adonisjs/session": "^7.0.0-15", + "@adonisjs/core": "^6.2.0", + "@adonisjs/lucid": "^19.0.0", + "@adonisjs/session": "^7.0.0", "@japa/api-client": "^2.0.2", "@japa/browser-client": "^2.0.2", - "@japa/plugin-adonisjs": "^2.0.1" + "@japa/plugin-adonisjs": "^3.0.0" }, "peerDependenciesMeta": { "@adonisjs/lucid": { diff --git a/src/authenticator.ts b/src/authenticator.ts index 585944f..eb6a259 100644 --- a/src/authenticator.ts +++ b/src/authenticator.ts @@ -127,13 +127,13 @@ export class Authenticator> { getUserOrFail(): { [K in keyof KnownGuards]: ReturnType['getUserOrFail']> }[keyof KnownGuards] { - if (!this.#authenticatedViaGuard) { + if (!this.#authenticationAttemptedViaGuard) { throw new RuntimeException( 'Cannot access authenticated user. Please call "auth.authenticate" method first.' ) } - return this.use(this.#authenticatedViaGuard).getUserOrFail() as { + return this.use(this.#authenticationAttemptedViaGuard).getUserOrFail() as { [K in keyof KnownGuards]: ReturnType['getUserOrFail']> }[keyof KnownGuards] } diff --git a/src/define_config.ts b/src/define_config.ts index 95fbd4a..9de3308 100644 --- a/src/define_config.ts +++ b/src/define_config.ts @@ -7,8 +7,6 @@ * file that was distributed with this source code. */ -/// - import { configProvider } from '@adonisjs/core' import type { ConfigProvider } from '@adonisjs/core/types' import type { GuardConfigProvider, GuardFactory } from './types.js' diff --git a/src/errors.ts b/src/errors.ts index 6909142..3db8927 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -8,8 +8,8 @@ */ import type { I18n } from '@adonisjs/i18n' -import { Exception } from '@poppinss/utils' -import { HttpContext } from '@adonisjs/core/http' +import { Exception } from '@adonisjs/core/exceptions' +import type { HttpContext } from '@adonisjs/core/http' /** * The "E_UNAUTHORIZED_ACCESS" exception is raised when unable to diff --git a/src/plugins/japa/browser_client.ts b/src/plugins/japa/browser_client.ts index 8853f25..c5a071c 100644 --- a/src/plugins/japa/browser_client.ts +++ b/src/plugins/japa/browser_client.ts @@ -10,9 +10,9 @@ /// /// -import { RuntimeException } from '@poppinss/utils' import type { PluginFn } from '@japa/runner/types' import { decoratorsCollection } from '@japa/browser-client' +import { RuntimeException } from '@adonisjs/core/exceptions' import type { ApplicationService } from '@adonisjs/core/types' import debug from '../../debug.js' diff --git a/tests/access_token/access_token.spec.ts b/tests/access_token/access_token.spec.ts deleted file mode 100644 index 58ee931..0000000 --- a/tests/access_token/access_token.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { Secret, base64 } from '@poppinss/utils' - -import { timeTravel } from '../helpers.js' -import { AccessToken } from '../../modules/access_token_guard/access_token.js' - -test.group('AccessToken token | decode', () => { - test('decode "{input}" as token') - .with([ - { - input: null, - output: null, - }, - { - input: '', - output: null, - }, - { - input: '..', - output: null, - }, - { - input: 'foobar', - output: null, - }, - { - input: 'foo.baz', - output: null, - }, - { - input: 'foo_baz.foo', - output: null, - }, - { - input: `api_bar.${base64.urlEncode('baz')}`, - output: null, - }, - { - input: `api_${base64.urlEncode('baz')}.bar`, - output: null, - }, - { - input: `api_${base64.urlEncode('baz')}.${base64.urlEncode('baz')}`, - output: null, - }, - { - input: `auth_token_`, - output: null, - }, - { - input: `auth_token_..`, - output: null, - }, - { - input: `auth_token_foo.bar`, - output: null, - }, - { - input: `auth_token_${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`, - output: { - identifier: 'bar', - seed: 'baz', - }, - }, - ]) - .run(({ assert }, { input, output }) => { - assert.deepEqual(AccessToken.decode('auth_token_', input as string), output) - }) -}) - -test.group('AccessToken token | create', () => { - test('create new token', ({ assert }) => { - const token = AccessToken.create('1', '20mins', 'auth_tokens_') - - assert.exists(token.hash) - assert.exists(token.value) - assert.instanceOf(token.value, Secret) - assert.instanceOf(token.createdAt, Date) - assert.instanceOf(token.updatedAt, Date) - assert.instanceOf(token.expiresAt, Date) - assert.isTrue(token.verify(AccessToken.decode('auth_tokens_', token.value!.release())!.seed)) - }) - - test('decode generated token', ({ assert }) => { - const token = AccessToken.create('1', '20mins', 'auth_tokens_') - const { seed, identifier } = AccessToken.decode('auth_tokens_', token.value!.release())! - - assert.equal(identifier, token.identifier) - assert.isTrue(token.verify(seed)) - }) - - test('check if token has been expired', ({ assert }) => { - const token = AccessToken.create('1', '20mins', 'auth_tokens') - assert.isFalse(token.isExpired()) - - timeTravel(21 * 60) - assert.isTrue(token.isExpired()) - }) -}) diff --git a/tests/access_token/guard/authenticate.spec.ts b/tests/access_token/guard/authenticate.spec.ts deleted file mode 100644 index 620b798..0000000 --- a/tests/access_token/guard/authenticate.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' - -import { createEmitter, timeTravel } from '../../helpers.js' -import { AccessTokenGuard } from '../../../modules/access_token_guard/guard.js' -import { AccessTokenFakeUserProvider } from '../../../factories/access_token_guard/main.js' - -test.group('Access token guard | authenticate', () => { - test('return user when access token is valid', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - const token = await userProvider.createToken(userProvider.findUser(1)!) - - ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` - const user = await guard.authenticate() - - assert.deepEqual(guard.user, user) - assert.deepEqual(guard.getUserOrFail(), user) - assert.isTrue(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('throw error when no authorization header exists', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - assert.isUndefined(guard.user) - assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('throw error when authorization header does not have a bearer token', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - ctx.request.request.headers.authorization = 'foo bar' - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - assert.isUndefined(guard.user) - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('throw error when authorization header has an empty bearer token', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - ctx.request.request.headers.authorization = 'Bearer ' - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - assert.isUndefined(guard.user) - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('throw error when bearer token is invalid', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - ctx.request.request.headers.authorization = 'Bearer helloworld' - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - assert.isUndefined(guard.user) - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('throw error when bearer token does not exist', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - const token = await userProvider.createToken(userProvider.findUser(1)!) - await userProvider.deleteToken(token.value!) - - ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - assert.isUndefined(guard.user) - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('throw error when bearer token has been expired', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - const token = await userProvider.createToken(userProvider.findUser(1)!) - timeTravel(21 * 60) - - ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - assert.isUndefined(guard.user) - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('multiple calls to authenticate method should be a noop', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - const token = await userProvider.createToken(userProvider.findUser(1)!) - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` - - /** - * Even though the token exists now, the authenticate - * method will use previous state - */ - await assert.rejects(() => guard.authenticate(), 'Unauthorized access') - - assert.isUndefined(guard.user) - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) -}) - -test.group('Access token guard | check', () => { - test('return true when access token is valid', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - const token = await userProvider.createToken(userProvider.findUser(1)!) - - ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` - const isLoggedIn = await guard.check() - - assert.isTrue(isLoggedIn) - assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) - assert.isTrue(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) - - test('return false when access token is invalid', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - const token = await userProvider.createToken(userProvider.findUser(1)!) - await userProvider.deleteToken(token.value!) - - ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` - const isLoggedIn = await guard.check() - - assert.isFalse(isLoggedIn) - assert.isUndefined(guard.user) - assert.isFalse(guard.isAuthenticated) - assert.isTrue(guard.authenticationAttempted) - }) -}) - -test.group('Access token guard | authenticateAsClient', () => { - test('return bearer header for client', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new AccessTokenFakeUserProvider() - - const guard = new AccessTokenGuard('api', ctx, emitter, userProvider) - const user = userProvider.findUser(1)! - - const clientState = await guard.authenticateAsClient(user) - assert.property(clientState, 'headers') - assert.property(clientState.headers, 'Authorization') - assert.match(clientState.headers!.Authorization, /Bearer oat_[a-zA-z0-9]+\.[a-zA-z0-9]/) - }) -}) diff --git a/tests/auth/define_config.spec.ts b/tests/auth/define_config.spec.ts index 57940da..17f8ba6 100644 --- a/tests/auth/define_config.spec.ts +++ b/tests/auth/define_config.spec.ts @@ -8,8 +8,8 @@ */ import { test } from '@japa/runner' -import { ApplicationService } from '@adonisjs/core/types' import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService } from '@adonisjs/core/types' import { AuthManager } from '../../src/auth_manager.js' import { FakeGuard } from '../../factories/auth/main.js' diff --git a/tests/helpers.ts b/tests/helpers.ts index 1162687..34a9583 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -23,9 +23,9 @@ import setCookieParser, { CookieMap } from 'set-cookie-parser' import { LoggerFactory } from '@adonisjs/core/factories/logger' import { EncryptionFactory } from '@adonisjs/core/factories/encryption' -import { SessionGuardEvents } from '../modules/session_guard/types.js' -import { FactoryUser } from '../backup/factories/core/lucid_user_provider.js' -import { AccessTokenGuardEvents } from '../modules/access_token_guard/types.js' +// import { SessionGuardEvents } from '../modules/session_guard/types.js' +// import { FactoryUser } from '../backup/factories/core/lucid_user_provider.js' +// import { AccessTokenGuardEvents } from '../modules/access_token_guard/types.js' export const encryption: Encryption = new EncryptionFactory().create() @@ -101,12 +101,12 @@ export async function createTables(db: Database) { }) await db.connection().schema.createTable('remember_me_tokens', (table) => { - table.string('series', 60).notNullable() + table.increments() table.integer('user_id').notNullable().unsigned() table.string('hash', 80).notNullable() - table.datetime('created_at').notNullable() - table.datetime('updated_at').notNullable() - table.datetime('expires_at').notNullable() + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').notNullable() + table.timestamp('expires_at').notNullable() }) } @@ -114,15 +114,15 @@ export async function createTables(db: Database) { * Creates an emitter instance for testing with typed * events */ -export function createEmitter() { - const test = getActiveTest() - if (!test) { - throw new Error('Cannot use "createEmitter" outside of a Japa test') - } - - const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) - return new Emitter & AccessTokenGuardEvents>(app) -} +// export function createEmitter() { +// const test = getActiveTest() +// if (!test) { +// throw new Error('Cannot use "createEmitter" outside of a Japa test') +// } + +// const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) +// return new Emitter & AccessTokenGuardEvents>(app) +// } /** * Promisify an event diff --git a/tests/session/guard/authenticate.spec.ts b/tests/session/guard/authenticate.spec.ts deleted file mode 100644 index a93c726..0000000 --- a/tests/session/guard/authenticate.spec.ts +++ /dev/null @@ -1,521 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { E_UNAUTHORIZED_ACCESS } from '../../../src/errors.js' -import { SessionGuard } from '../../../modules/session_guard/guard.js' -import { createEmitter, defineCookies, timeTravel } from '../../helpers.js' -import { SessionFakeUserProvider } from '../../../factories/session_guard/main.js' -import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' - -test.group('Session guard | authenticate | via session', () => { - test('mark user as logged-in when a valid session exists', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const user = await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 1) // setting auth state for userId 1 - return guard.authenticate() - }) - - assert.isTrue(guard.authenticationAttempted) - assert.isTrue(guard.isAuthenticated) - assert.isDefined(guard.user) - assert.deepEqual(guard.user, user) - assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - }) - - test('throw error when session does not exist', async ({ assert }) => { - assert.plan(8) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - } catch (error) { - assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) - assert.equal(error.message, 'Invalid or expired user session') - assert.equal(error.guardDriverName, 'session') - } - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - }) - - test('throw error when user does not exist', async ({ assert }) => { - assert.plan(8) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - try { - await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 10) // there is no user with id 10 - return guard.authenticate() - }) - } catch (error) { - assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) - assert.equal(error.message, 'Invalid or expired user session') - assert.equal(error.guardDriverName, 'session') - } - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - }) -}) - -test.group('Session guard | authenticate | via remember me cookie', () => { - test('create user session and mark them as logged-in via remember cookie', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - - const originalExpiryTime = token.expiresAt.getTime() - const originalUpdatedAtTime = token.updatedAt.getTime() - const originalHash = token.hash - const originalSeries = token.series - - userProvider.useToken(token) - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - const user = await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - - assert.isTrue(guard.authenticationAttempted) - assert.isTrue(guard.isAuthenticated) - assert.isDefined(guard.user) - assert.deepEqual(guard.user, user) - assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) - assert.isFalse(guard.isLoggedOut) - assert.isTrue(guard.viaRemember) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - - assert.equal(token.expiresAt.getTime(), originalExpiryTime) - assert.equal(token.updatedAt.getTime(), originalUpdatedAtTime) - assert.equal(token.hash, originalHash) - assert.equal(token.series, originalSeries) - }) - - test('recycle token when the existing token is older than 1 minute', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - const originalExpiryTime = token.expiresAt.getTime() - const originalUpdatedAtTime = token.updatedAt.getTime() - const originalHash = token.hash - const originalSeries = token.series - - userProvider.useToken(token) - timeTravel(120) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - const user = await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - - assert.isTrue(guard.authenticationAttempted) - assert.isTrue(guard.isAuthenticated) - assert.isDefined(guard.user) - assert.deepEqual(guard.user, user) - assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) - assert.isFalse(guard.isLoggedOut) - assert.isTrue(guard.viaRemember) - assert.deepEqual(ctx.session.all(), { auth_web: user.id }) - - assert.isAbove(token.expiresAt.getTime(), originalExpiryTime) - assert.isAbove(token.updatedAt.getTime(), originalUpdatedAtTime) - assert.notEqual(token.hash, originalHash) - assert.equal(token.series, originalSeries) - }) - - test('throw error when token has been expired', async ({ assert }) => { - assert.plan(9) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - - userProvider.useToken(token) - timeTravel(21 * 60) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - } catch (error) { - assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) - assert.equal(error.message, 'Invalid or expired user session') - assert.equal(error.guardDriverName, 'session') - } - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - assert.deepEqual(ctx.session.all(), {}) - }) - - test('throw error when token does not exist', async ({ assert }) => { - assert.plan(9) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - } catch (error) { - assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) - assert.equal(error.message, 'Invalid or expired user session') - assert.equal(error.guardDriverName, 'session') - } - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - assert.deepEqual(ctx.session.all(), {}) - }) - - test('throw error when remember me cookie does exist', async ({ assert }) => { - assert.plan(9) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - userProvider.useToken(token) - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - } catch (error) { - assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) - assert.equal(error.message, 'Invalid or expired user session') - assert.equal(error.guardDriverName, 'session') - } - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - assert.deepEqual(ctx.session.all(), {}) - }) - - test('throw error when token is malformed', async ({ assert }) => { - assert.plan(9) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - - userProvider.useToken(token) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: 'foo', - type: 'encrypted', - }, - ]) - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - } catch (error) { - assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) - assert.equal(error.message, 'Invalid or expired user session') - assert.equal(error.guardDriverName, 'session') - } - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - assert.deepEqual(ctx.session.all(), {}) - }) - - test('throw error when user does not exist', async ({ assert }) => { - assert.plan(9) - - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(10, '20 mins') - userProvider.useToken(token) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.authenticate() - }) - } catch (error) { - assert.instanceOf(error, E_UNAUTHORIZED_ACCESS) - assert.equal(error.message, 'Invalid or expired user session') - assert.equal(error.guardDriverName, 'session') - } - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - assert.deepEqual(ctx.session.all(), {}) - }) -}) - -test.group('Session guard | authenticate', () => { - test('multiple calls to authenticate should be a noop', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - /** - * Fails for the first time - */ - await assert.rejects(() => guard.authenticate()) - - /** - * Then we setup the session - */ - await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 1) - }) - - /** - * But still unauthenticated, because the method does not - * re-form the authentication - */ - await assert.rejects(() => guard.authenticate()) - - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - }) -}) - -test.group('Session guard | check', () => { - test('return true when user is logged-in', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const isLoggedIn = await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 1) // setting auth state for userId 1 - return guard.check() - }) - - assert.isTrue(isLoggedIn) - assert.isTrue(guard.authenticationAttempted) - assert.isTrue(guard.isAuthenticated) - assert.isDefined(guard.user) - assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - }) - - test('return false when user is not logged-in', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - const isLoggedIn = await sessionMiddleware.handle(ctx, () => { - return guard.check() - }) - - assert.isFalse(isLoggedIn) - assert.isTrue(guard.authenticationAttempted) - assert.isFalse(guard.isAuthenticated) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - }) - - test('re-throw errors other than the E_UNAUTHORIZED_ACCESS', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - - await assert.rejects( - () => guard.check(), - 'Cannot authenticate user. Install and configure "@adonisjs/session" package' - ) - }) -}) - -test.group('Session guard | getUserOrFail', () => { - test('return user when user is logged-in', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 1) // setting auth state for userId 1 - return guard.authenticate() - }) - - assert.isTrue(guard.authenticationAttempted) - assert.isTrue(guard.isAuthenticated) - assert.deepEqual(guard.user, guard.getUserOrFail()) - assert.deepEqual(guard.getUserOrFail(), { - id: 1, - email: 'virk@adonisjs.com', - password: 'secret', - }) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.viaRemember) - }) - - test('throw error when user is not logged-in', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') - await sessionMiddleware.handle(ctx, () => { - return guard.check() - }) - - assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') - }) -}) - -test.group('Session guard | authenticateAsClient', () => { - test('return session state for client login', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - assert.deepEqual( - await guard.authenticateAsClient((await userProvider.findById(1))!.getOriginal()), - { - session: { - auth_web: 1, - }, - } - ) - }) -}) diff --git a/tests/session/guard/login.spec.ts b/tests/session/guard/login.spec.ts deleted file mode 100644 index 6314173..0000000 --- a/tests/session/guard/login.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { createEmitter, parseCookies } from '../../helpers.js' -import { SessionGuard } from '../../../modules/session_guard/guard.js' -import { SessionFakeUserProvider } from '../../../factories/session_guard/main.js' -import { E_INVALID_CREDENTIALS } from '../../../src/errors.js' - -test.group('Session guard | login', () => { - test('create session for the user', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const user = await userProvider.findById(1) - - await sessionMiddleware.handle(ctx, () => { - return guard.login(user!.getOriginal()) - }) - - assert.deepEqual(ctx.session.all(), { auth_web: 1 }) - assert.deepEqual(guard.user, user!.getOriginal()) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - }) - - test('generate remember me token cookie', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const user = await userProvider.findById(1) - - await sessionMiddleware.handle(ctx, () => { - return guard.login(user!.getOriginal(), true) - }) - - assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { - remember_web: { - httpOnly: true, - name: 'remember_web', - path: '/', - value: userProvider.getToken()!.value!.release(), - }, - }) - assert.deepEqual(ctx.session.all(), { auth_web: 1 }) - assert.deepEqual(guard.user, user!.getOriginal()) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - }) -}) - -test.group('Session guard | loginViaId', () => { - test('create session for the user by id', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, () => { - return guard.loginViaId(1) - }) - - assert.deepEqual(ctx.session.all(), { auth_web: 1 }) - assert.deepEqual(guard.user, { email: 'virk@adonisjs.com', id: 1, password: 'secret' }) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - }) - - test('throw error when user for the id does not exist', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.loginViaId(10) - }) - } catch (error) { - assert.instanceOf(error, E_INVALID_CREDENTIALS) - assert.equal(error.message, 'Invalid user credentails') - assert.equal(error.guardDriverName, 'session') - } - - assert.deepEqual(ctx.session.all(), {}) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - }) -}) - -test.group('Session guard | attempt', () => { - test('create session for the user after verifying credentials', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, () => { - return guard.attempt('virk@adonisjs.com', 'secret') - }) - - assert.deepEqual(ctx.session.all(), { auth_web: 1 }) - assert.deepEqual(guard.user, { email: 'virk@adonisjs.com', id: 1, password: 'secret' }) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - }) - - test('throw error when credentials are invalid', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - try { - await sessionMiddleware.handle(ctx, () => { - return guard.attempt('virk@adonisjs.com', 'foo') - }) - } catch (error) { - assert.instanceOf(error, E_INVALID_CREDENTIALS) - assert.equal(error.message, 'Invalid user credentails') - assert.equal(error.guardDriverName, 'session') - } - - assert.deepEqual(ctx.session.all(), {}) - assert.isUndefined(guard.user) - assert.isFalse(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - }) -}) diff --git a/tests/session/guard/logout.spec.ts b/tests/session/guard/logout.spec.ts deleted file mode 100644 index b946c89..0000000 --- a/tests/session/guard/logout.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { SessionMiddlewareFactory } from '@adonisjs/session/factories' - -import { createEmitter, defineCookies, parseCookies } from '../../helpers.js' -import { SessionGuard } from '../../../modules/session_guard/guard.js' -import { SessionFakeUserProvider } from '../../../factories/session_guard/main.js' -import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' - -test.group('Session guard | logout', () => { - test('delete user session', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - - await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 1) - return guard.logout() - }) - - assert.deepEqual(ctx.session.all(), {}) - assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { - remember_web: { - maxAge: -1, - name: 'remember_web', - value: '', - expires: new Date(0), - }, - }) - assert.isUndefined(guard.user) - assert.isTrue(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - assert.isFalse(guard.viaRemember) - }) - - test('delete remember token when one exists', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - userProvider.useToken(token) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: token.value!.release(), - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 1) - return guard.logout() - }) - - assert.deepEqual(ctx.session.all(), {}) - assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { - remember_web: { - maxAge: -1, - name: 'remember_web', - value: '', - expires: new Date(0), - }, - }) - assert.isUndefined(guard.user) - assert.isTrue(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - assert.isFalse(guard.viaRemember) - assert.isUndefined(userProvider.getToken()) - }) - - test('do not delete token when value in cookie is invalid', async ({ assert }) => { - const ctx = new HttpContextFactory().create() - const emitter = createEmitter() - const userProvider = new SessionFakeUserProvider() - - const guard = new SessionGuard('web', {}, ctx, emitter, userProvider) - const sessionMiddleware = await new SessionMiddlewareFactory().create() - const token = RememberMeToken.create(1, '20 mins') - userProvider.useToken(token) - - ctx.request.request.headers.cookie = defineCookies([ - { - key: 'remember_web', - value: 'foo', - type: 'encrypted', - }, - ]) - - await sessionMiddleware.handle(ctx, () => { - ctx.session.put('auth_web', 1) - return guard.logout() - }) - - assert.deepEqual(ctx.session.all(), {}) - assert.containsSubset(parseCookies(ctx.response.getHeader('set-cookie') as string), { - remember_web: { - maxAge: -1, - name: 'remember_web', - value: '', - expires: new Date(0), - }, - }) - assert.isUndefined(guard.user) - assert.isTrue(guard.isLoggedOut) - assert.isFalse(guard.isAuthenticated) - assert.isFalse(guard.authenticationAttempted) - assert.isFalse(guard.viaRemember) - assert.isDefined(userProvider.getToken()) - }) -}) diff --git a/tests/session/remember_me_token.spec.ts b/tests/session/remember_me_token.spec.ts deleted file mode 100644 index 1e53a92..0000000 --- a/tests/session/remember_me_token.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { setTimeout } from 'node:timers/promises' -import { Secret, base64 } from '@adonisjs/core/helpers' - -import { freezeTime } from '../helpers.js' -import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' - -test.group('Remember me token', () => { - test('create a remember me token', ({ assert }) => { - freezeTime() - const date = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(date.getSeconds() + 60 * 20) - - const token = RememberMeToken.create(1, '20mins') - assert.equal(token.userId, 1) - assert.equal(token.createdAt.getTime(), date.getTime()) - assert.equal(token.updatedAt.getTime(), date.getTime()) - assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) - assert.lengthOf(token.series, 15) - assert.instanceOf(token.value, Secret) - assert.isTrue(token.verify(RememberMeToken.decode(token.value!.release())!.value)) - assert.isFalse(token.isExpired()) - }) - - test('create token from persisted information', ({ assert }) => { - const createdAt = new Date() - const updatedAt = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) - - const token = RememberMeToken.createFromPersisted({ - userId: 1, - hash: '1234', - createdAt, - updatedAt, - expiresAt, - series: '1', - }) - - assert.equal(token.series, '1') - assert.equal(token.hash, '1234') - assert.equal(token.userId, 1) - assert.equal(token.userId, 1) - assert.equal(token.createdAt.getTime(), createdAt.getTime()) - assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) - assert.equal(token.expiresAt.getTime(), expiresAt.getTime()) - assert.isUndefined(token.value) - assert.isFalse(token.isExpired()) - }) - - test('refresh remember me token', async ({ assert }) => { - const createdAt = new Date() - const updatedAt = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) - - const token = RememberMeToken.createFromPersisted({ - userId: 1, - hash: '1234', - createdAt, - updatedAt, - expiresAt, - series: '1', - }) - - await setTimeout(100) - token.refresh('20mins') - - assert.isDefined(token.value) - assert.notEqual(token.hash, '1234') - assert.isAbove(token.updatedAt.getTime(), updatedAt.getTime()) - assert.equal(token.expiresAt.getTime(), token.updatedAt.getTime() + 60 * 20 * 1000) - }) - - test('verify token hash', ({ assert }) => { - freezeTime() - const date = new Date() - const expiresAt = new Date() - expiresAt.setSeconds(date.getSeconds() + 60 * 20) - - const token = RememberMeToken.create(1, '20mins') - assert.isTrue(token.verify(base64.urlDecode(token.value!.release().split('.')[1])!)) - }) - - test('decode remember me token', ({ assert }) => { - const token = RememberMeToken.create(1, '20mins') - const { series, value } = RememberMeToken.decode(token.value!.release())! - - assert.equal(series, token.series) - assert.isTrue(token.verify(value)) - }) - - test('decode "{input}" as token') - .with([ - { - input: null, - output: null, - }, - { - input: '', - output: null, - }, - { - input: '..', - output: null, - }, - { - input: 'foobar', - output: null, - }, - { - input: 'foo.bar', - output: null, - }, - { - input: 'baz.foo', - output: null, - }, - { - input: `bar.${base64.urlEncode('baz')}`, - output: null, - }, - { - input: `${base64.urlEncode('bar')}.baz`, - output: null, - }, - { - input: `${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`, - output: { - series: 'bar', - value: 'baz', - }, - }, - ]) - .run(({ assert }, { input, output }) => { - assert.deepEqual(RememberMeToken.decode(input as string), output) - }) -}) diff --git a/tests/session/user_providers/lucid.spec.ts b/tests/session/user_providers/lucid.spec.ts deleted file mode 100644 index e26e5c3..0000000 --- a/tests/session/user_providers/lucid.spec.ts +++ /dev/null @@ -1,630 +0,0 @@ -/* - * @adonisjs/auth - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { compose } from '@poppinss/utils' -import convertHrtime from 'convert-hrtime' -import { BaseModel, column } from '@adonisjs/lucid/orm' - -import { createDatabase, createTables, getHasher, timeTravel } from '../../helpers.js' -import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' -import { SessionLucidUserProvider } from '../../../modules/session_guard/providers/lucid.js' -import { withRememberMeTokens } from '../../../modules/session_guard/models/remember_me_token.js' - -class User extends BaseModel { - @column() - declare id: number - - @column() - declare username: string - - @column() - declare email: string - - @column() - declare password: string | null -} - -test.group('Session lucid user provider | findById', () => { - test('return guard user instance', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await User.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const user = await userProvider.findById(1) - - expectTypeOf(user!.getOriginal()).toEqualTypeOf() - assert.instanceOf(user!.getOriginal(), User) - assert.equal(user!.getId(), 1) - }) - - test('return null when no user exists', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - const user = await userProvider.findById(1) - assert.isNull(user) - }) -}) - -test.group('Session lucid user provider | findByUid', () => { - test('return guard user instance', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await User.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const user = await userProvider.findByUid('virk@adonisjs.com') - - expectTypeOf(user!.getOriginal()).toEqualTypeOf() - assert.instanceOf(user!.getOriginal(), User) - }) - - test('return null when no user exists', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - const user = await userProvider.findByUid(1) - assert.isNull(user) - }) - - test('use custom lookup method when defined on the model', async ({ assert, expectTypeOf }) => { - assert.plan(2) - - const db = await createDatabase() - await createTables(db) - - class AuthUser extends User { - static async getUserForAuth(uids: string[], value: string | number) { - assert.deepEqual(uids, ['username', 'email']) - - const query = this.query() - uids.forEach((uid) => query.orWhere(uid, value)) - return query.first() - } - } - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await User.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const user = await userProvider.findByUid('virk@adonisjs.com') - - expectTypeOf(user!.getOriginal()).toEqualTypeOf() - assert.instanceOf(user!.getOriginal(), User) - }) - - test('return null when custom method does not return a user', async ({ assert }) => { - assert.plan(2) - - const db = await createDatabase() - await createTables(db) - - class AuthUser extends User { - static async getUserForAuth(uids: string[], value: string | number) { - assert.deepEqual(uids, ['username', 'email']) - - const query = this.query() - uids.forEach((uid) => query.orWhere(uid, value)) - return query.first() - } - } - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - const user = await userProvider.findByUid('virk@adonisjs.com') - assert.isNull(user) - }) -}) - -test.group('Session lucid user provider | verifyCredentials', () => { - test('return guard user instance when credentials are valid', async ({ - assert, - expectTypeOf, - }) => { - const db = await createDatabase() - const hasher = getHasher() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(hasher, { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await User.create({ - email: 'virk@adonisjs.com', - password: await hasher.make('secret'), - username: 'virk', - }) - const user = await userProvider.verifyCredentials('virk@adonisjs.com', 'secret') - - expectTypeOf(user!.getOriginal()).toEqualTypeOf() - assert.instanceOf(user!.getOriginal(), User) - }) - - test('return null when password is invalid', async ({ assert }) => { - const db = await createDatabase() - const hasher = getHasher() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(hasher, { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await User.create({ - email: 'virk@adonisjs.com', - password: await hasher.make('secret'), - username: 'virk', - }) - - const user = await userProvider.verifyCredentials('virk@adonisjs.com', 'supersecret') - assert.isNull(user) - }) - - test('return null when unable to find user', async ({ assert }) => { - const db = await createDatabase() - const hasher = getHasher() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(hasher, { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - const user = await userProvider.verifyCredentials('virk@adonisjs.com', 'secret') - assert.isNull(user) - }) - - test('throw error when user does not have a password column', async ({ assert }) => { - const db = await createDatabase() - const hasher = getHasher() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(hasher, { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await User.create({ - email: 'virk@adonisjs.com', - password: null, - username: 'virk', - }) - - await assert.rejects( - () => userProvider.verifyCredentials('virk@adonisjs.com', 'secret'), - 'Cannot verify password during login. The value of column "password" is undefined or null' - ) - }) - - test('prevent timing attacks', async ({ assert }) => { - const db = await createDatabase() - const hasher = getHasher() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(hasher, { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await User.create({ - email: 'virk@adonisjs.com', - password: await hasher.make('secret'), - username: 'virk', - }) - - let startTime = process.hrtime.bigint() - await userProvider.verifyCredentials('foo@adonisjs.com', 'secret') - const invalidEmailTime = convertHrtime(process.hrtime.bigint() - startTime) - - startTime = process.hrtime.bigint() - await userProvider.verifyCredentials('virk@adonisjs.com', 'supersecret') - const invalidPasswordTime = convertHrtime(process.hrtime.bigint() - startTime) - - /** - * Same timing within the range of 10 milliseconds is acceptable - */ - assert.isBelow(Math.abs(invalidPasswordTime.seconds - invalidEmailTime.seconds), 1) - assert.isBelow(Math.abs(invalidPasswordTime.milliseconds - invalidEmailTime.milliseconds), 10) - }) -}) - -test.group('Session lucid user provider | guardUser', () => { - test('create guard user from model instance', async ({ assert, expectTypeOf }) => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - const user = await User.create({ - email: 'virk@adonisjs.com', - password: 'secret', - username: 'virk', - }) - const guardUser = await userProvider.createUserForGuard(user) - - expectTypeOf(guardUser!.getOriginal()).toEqualTypeOf() - assert.instanceOf(guardUser!.getOriginal(), User) - assert.strictEqual(guardUser!.getOriginal(), user) - }) - - test('throw error when user input is invalid', async () => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - // @ts-expect-error - await userProvider.createUserForGuard({}) - }).throws('Invalid user object. It must be an instance of the "User" model') - - test('throw error when user does not have an id', async () => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - const user = await userProvider.createUserForGuard(new User()) - user.getId() - }).throws( - 'Cannot use "User" model for authentication. The value of column "id" is undefined or null' - ) -}) - -test.group('Session lucid user provider | rememberTokens | create', () => { - test('throw error when not using withRememberMeTokens mixin', async () => { - const db = await createDatabase() - await createTables(db) - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: User, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - const token = RememberMeToken.create(1, '20 mins') - await userProvider.createRememberMeToken(token) - }).throws( - 'Cannot perist remember me token using "User" model. Make sure to use "withRememberMeTokens" mixin' - ) - - test('create a token for the user', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - await userProvider.createRememberMeToken(token) - - const tokens = await AuthUser.rememberMeTokens.all() - assert.deepEqual(tokens[0].$attributes, { - userId: 1, - createdAt: token.createdAt.getTime(), - updatedAt: token.updatedAt.getTime(), - expiresAt: token.expiresAt.getTime(), - series: token.series, - hash: token.hash, - }) - }) -}) - -test.group('Session lucid user provider | rememberTokens | find', () => { - test('find token by series', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - await userProvider.createRememberMeToken(token) - const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) - - assert.instanceOf(rememberMeToken, RememberMeToken) - assert.equal(rememberMeToken!.expiresAt.getTime(), token.expiresAt.getTime()) - assert.equal(rememberMeToken!.updatedAt.getTime(), token.updatedAt.getTime()) - assert.equal(rememberMeToken!.createdAt.getTime(), token.createdAt.getTime()) - assert.equal(rememberMeToken!.hash, token.hash) - assert.equal(rememberMeToken!.series, token.series) - }) - - test('return null when token is missing', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - - const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) - assert.isNull(rememberMeToken) - }) - - test('return null when token has been expired', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - await userProvider.createRememberMeToken(token) - - timeTravel(21 * 60) - - const rememberMeToken = await userProvider.findRememberMeTokenBySeries(token.series) - assert.isNull(rememberMeToken) - }) -}) - -test.group('Session lucid user provider | rememberTokens | recycle', () => { - test('update token hash and timestamps', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - const existingHash = token.hash - const existingExpiresAt = token.expiresAt.getTime() - const existingUpdateAt = token.updatedAt.getTime() - - await userProvider.createRememberMeToken(token) - - token.refresh('30 mins') - await userProvider.recycleRememberMeToken(token) - - const tokens = await AuthUser.rememberMeTokens.all() - assert.equal(tokens[0].hash, token.hash) - assert.equal(tokens[0].expiresAt, token.expiresAt.getTime()) - assert.equal(tokens[0].updatedAt, token.updatedAt.getTime()) - assert.notEqual(tokens[0].expiresAt, existingExpiresAt) - assert.notEqual(tokens[0].updatedAt, existingUpdateAt) - assert.notEqual(tokens[0].hash, existingHash) - }) - - test('noop when no tokens exists in first place', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - token.refresh('30 mins') - await userProvider.recycleRememberMeToken(token) - - const tokens = await AuthUser.rememberMeTokens.all() - assert.lengthOf(tokens, 0) - }) -}) - -test.group('Session lucid user provider | rememberTokens | delete', () => { - test('delete token by series', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - await userProvider.createRememberMeToken(token) - await userProvider.deleteRememberMeTokenBySeries(token.series) - - const tokens = await AuthUser.rememberMeTokens.all() - assert.lengthOf(tokens, 0) - }) - - test('noop when no tokens exists in first place', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class AuthUser extends compose(User, withRememberMeTokens()) {} - - const userProvider = new SessionLucidUserProvider(getHasher(), { - model: async () => { - return { - default: AuthUser, - } - }, - uids: ['username', 'email'], - passwordColumnName: 'password', - }) - - await AuthUser.create({ email: 'virk@adonisjs.com', password: 'secret', username: 'virk' }) - const token = RememberMeToken.create(1, '20 mins') - await userProvider.createRememberMeToken(token) - await userProvider.deleteRememberMeTokenBySeries('foo') - - const tokens = await AuthUser.rememberMeTokens.all() - assert.lengthOf(tokens, 1) - }) -}) From 2bce16567c49cc98d78ed5922ed1b17973b475ea Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 15 Jan 2024 12:09:27 +0530 Subject: [PATCH 71/96] feat: implement access tokens guard --- bin/test.ts | 4 +- factories/access_tokens/main.ts | 158 +++++++++ factories/auth/main.ts | 3 +- modules/access_tokens_guard/access_token.ts | 233 ++++++++++++ modules/access_tokens_guard/crc32.ts | 101 ++++++ modules/access_tokens_guard/guard.ts | 229 ++++++++++++ .../access_tokens_guard/token_providers/db.ts | 224 ++++++++++++ modules/access_tokens_guard/types.ts | 226 ++++++++++++ .../user_providers/lucid.ts | 133 +++++++ .../access_tokens/guard/authenticate.spec.ts | 335 ++++++++++++++++++ tests/helpers.ts | 24 +- 11 files changed, 1653 insertions(+), 17 deletions(-) create mode 100644 factories/access_tokens/main.ts create mode 100644 modules/access_tokens_guard/access_token.ts create mode 100644 modules/access_tokens_guard/crc32.ts create mode 100644 modules/access_tokens_guard/guard.ts create mode 100644 modules/access_tokens_guard/token_providers/db.ts create mode 100644 modules/access_tokens_guard/types.ts create mode 100644 modules/access_tokens_guard/user_providers/lucid.ts create mode 100644 tests/access_tokens/guard/authenticate.spec.ts diff --git a/bin/test.ts b/bin/test.ts index 7775105..cce33f0 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -12,8 +12,8 @@ configure({ files: ['tests/session/**/*.spec.ts'], }, { - name: 'access_token', - files: ['tests/access_token/**/*.spec.ts'], + name: 'access_tokens', + files: ['tests/access_tokens/**/*.spec.ts'], }, { name: 'auth', diff --git a/factories/access_tokens/main.ts b/factories/access_tokens/main.ts new file mode 100644 index 0000000..af929fc --- /dev/null +++ b/factories/access_tokens/main.ts @@ -0,0 +1,158 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Secret } from '@adonisjs/core/helpers' +import stringHelpers from '@adonisjs/core/helpers/string' +import { PROVIDER_REAL_USER } from '../../src/symbols.js' +import { AccessToken } from '../../modules/access_tokens_guard/access_token.js' +import { AccessTokensUserProviderContract } from '../../modules/access_tokens_guard/types.js' + +/** + * Representation of a fake user used to test + * the access token guard. + * + * @note + * Should not be exported to the outside world + */ +export type AccessTokensFakeUser = { + id: number + email: string + password: string +} + +/** + * Collection of dummy users + */ +const users: AccessTokensFakeUser[] = [ + { + id: 1, + email: 'virk@adonisjs.com', + password: 'secret', + }, + { + id: 2, + email: 'romain@adonisjs.com', + password: 'secret', + }, +] + +/** + * Implementation of a user provider to be used by session guard for + * authentication. Used for testing. + * + * @note + * Should not be exported to the outside world + */ +export class AccessTokensFakeUserProvider + implements AccessTokensUserProviderContract +{ + declare [PROVIDER_REAL_USER]: AccessTokensFakeUser + #tokens: { + id: string + tokenableId: number + type: string + abilities: string + hash: string + createdAt: Date + updatedAt: Date + lastUsedAt: Date | null + expiresAt: Date | null + }[] = [] + + deleteToken(identifier: string | number | BigInt) { + this.#tokens = this.#tokens.filter((token) => token.id !== identifier) + } + + async createToken( + user: AccessTokensFakeUser, + expiresIn?: string | number, + abilities?: string[] + ): Promise { + const transientToken = AccessToken.createTransientToken(user.id, 40, expiresIn) + const id = stringHelpers.random(15) + const createdAt = new Date() + const updatedAt = new Date() + + this.#tokens.push({ + id, + createdAt, + updatedAt, + hash: transientToken.hash, + lastUsedAt: null, + tokenableId: user.id, + type: 'auth_tokens', + expiresAt: transientToken.expiresAt || null, + abilities: JSON.stringify(abilities || ['*']), + }) + + return new AccessToken({ + identifier: id, + abilities: abilities || ['*'], + tokenableId: user.id, + secret: transientToken.secret, + prefix: 'oat_', + type: 'auth_tokens', + hash: transientToken.hash, + createdAt: createdAt, + updatedAt: updatedAt, + expiresAt: transientToken.expiresAt || null, + lastUsedAt: null, + }) + } + + async createUserForGuard(user: AccessTokensFakeUser) { + return { + getId() { + return user.id + }, + getOriginal() { + return user + }, + } + } + + async findById(id: number) { + const user = users.find(({ id: userId }) => userId === id) + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + async verifyToken(tokenValue: Secret): Promise { + const decodedToken = AccessToken.decode('oat_', tokenValue.release()) + if (!decodedToken) { + return null + } + + const token = this.#tokens.find(({ id }) => id === decodedToken.identifier) + if (!token) { + return null + } + + const accessToken = new AccessToken({ + identifier: token.id, + abilities: JSON.parse(token.abilities), + tokenableId: token.tokenableId, + type: token.type, + hash: token.hash, + createdAt: token.createdAt, + updatedAt: token.updatedAt, + expiresAt: token.expiresAt, + lastUsedAt: token.lastUsedAt, + }) + + if (!accessToken.verify(decodedToken.secret) || accessToken.isExpired()) { + return null + } + + return accessToken + } +} diff --git a/factories/auth/main.ts b/factories/auth/main.ts index 9f48652..e4bc806 100644 --- a/factories/auth/main.ts +++ b/factories/auth/main.ts @@ -21,7 +21,8 @@ export type FakeUser = { /** * Fake guard is an implementation of the auth guard contract - * that uses in-memory values used for testing. + * that uses in-memory values used for testing the auth + * layer. * * @note * Should not be exported to the outside world diff --git a/modules/access_tokens_guard/access_token.ts b/modules/access_tokens_guard/access_token.ts new file mode 100644 index 0000000..f96608f --- /dev/null +++ b/modules/access_tokens_guard/access_token.ts @@ -0,0 +1,233 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHash } from 'node:crypto' +import string from '@adonisjs/core/helpers/string' +import { RuntimeException } from '@adonisjs/core/exceptions' +import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' + +import { CRC32 } from './crc32.js' + +/** + * Access token represents a token created for a user to authenticate + * using the auth module. + * + * It encapsulates the logic of creating an opaque token, generating + * its hash and verifying its hash. + */ +export class AccessToken { + /** + * Decodes a publicly shared token and return the series + * and the token value from it. + * + * Returns null when unable to decode the token because of + * invalid format or encoding. + */ + static decode( + prefix: string, + value: string + ): null | { identifier: string; secret: Secret } { + /** + * Ensure value is a string and starts with the prefix. + */ + if (typeof value !== 'string' || !value.startsWith(`${prefix}`)) { + return null + } + + /** + * Remove prefix from the rest of the token. + */ + const token = value.replace(new RegExp(`^${prefix}`), '') + if (!token) { + return null + } + + const [identifier, ...tokenValue] = token.split('.') + if (!identifier || tokenValue.length === 0) { + return null + } + + const decodedIdentifier = base64.urlDecode(identifier) + const decodedSecret = base64.urlDecode(tokenValue.join('.')) + if (!decodedIdentifier || !decodedSecret) { + return null + } + + return { + identifier: decodedIdentifier, + secret: new Secret(decodedSecret), + } + } + + /** + * Creates a transient token that can be shared with the persistence + * layer. + */ + static createTransientToken( + userId: string | number | BigInt, + size: number, + expiresIn?: string | number + ) { + let expiresAt: Date | undefined + if (expiresIn) { + expiresAt = new Date() + expiresAt.setSeconds(expiresAt.getSeconds() + string.seconds.parse(expiresIn)) + } + + return { + userId, + expiresAt, + ...this.seed(size), + } + } + + /** + * Creates a secret opaque token and its hash. The secret is + * suffixed with a crc32 checksum for secret scanning tools + * to easily identify the token. + */ + static seed(size: number) { + const seed = string.random(size) + const secret = new Secret(`${seed}${new CRC32().calculate(seed)}`) + const hash = createHash('sha256').update(secret.release()).digest('hex') + return { secret, hash } + } + + /** + * Identifer is a unique sequence to identify the + * token within database. It should be the + * primary/unique key + */ + identifier: string | number | BigInt + + /** + * Reference to the user id for whom the token + * is generated. + */ + tokenableId: string | number | BigInt + + /** + * The value is a public representation of a token. It is created + * by combining the "identifier"."secret" + */ + value?: Secret + + /** + * A unique type to identify a bucket of tokens inside the + * storage layer. + */ + type: string + + /** + * Hash is computed from the seed to later verify the validity + * of seed + */ + hash: string + + /** + * Date/time when the token instance was created + */ + createdAt: Date + + /** + * Date/time when the token was updated + */ + updatedAt: Date + + /** + * Timestamp at which the token was used for authentication + */ + lastUsedAt: Date | null + + /** + * Timestamp at which the token will expire + */ + expiresAt: Date | null + + /** + * An array of abilities the token can perform. The abilities + * is an array of abritary string values + */ + abilities: string[] + + constructor(attributes: { + identifier: string | number | BigInt + tokenableId: string | number | BigInt + type: string + hash: string + createdAt: Date + updatedAt: Date + lastUsedAt: Date | null + expiresAt: Date | null + prefix?: string + secret?: Secret + abilities?: string[] + }) { + this.identifier = attributes.identifier + this.tokenableId = attributes.tokenableId + this.hash = attributes.hash + this.type = attributes.type + this.createdAt = attributes.createdAt + this.updatedAt = attributes.updatedAt + this.expiresAt = attributes.expiresAt + this.lastUsedAt = attributes.lastUsedAt + this.abilities = attributes.abilities || ['*'] + + /** + * Compute value when secret is provided + */ + if (attributes.secret) { + if (!attributes.prefix) { + throw new RuntimeException('Cannot compute token value without the prefix') + } + this.value = new Secret( + `${attributes.prefix}${base64.urlEncode(String(this.identifier))}.${base64.urlEncode( + attributes.secret.release() + )}` + ) + } + } + + /** + * Check if the token allows the given ability. + */ + allows(ability: string) { + return this.abilities.includes(ability) || this.abilities.includes('*') + } + + /** + * Check if the token denies the ability. + */ + denies(ability: string) { + return !this.abilities.includes(ability) && !this.abilities.includes('*') + } + + /** + * Check if the token has been expired. Verifies + * the "expiresAt" timestamp with the current + * date. + * + * Tokens with no expiry never expire + */ + isExpired() { + if (!this.expiresAt) { + return false + } + + return this.expiresAt < new Date() + } + + /** + * Verifies the value of a token against the pre-defined hash + */ + verify(secret: Secret): boolean { + const newHash = createHash('sha256').update(secret.release()).digest('hex') + return safeEqual(this.hash, newHash) + } +} diff --git a/modules/access_tokens_guard/crc32.ts b/modules/access_tokens_guard/crc32.ts new file mode 100644 index 0000000..974184e --- /dev/null +++ b/modules/access_tokens_guard/crc32.ts @@ -0,0 +1,101 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * We use CRC32 just to add a recognizable checksum to tokens. This helps + * secret scanning tools like https://docs.github.com/en/github/administering-a-repository/about-secret-scanning easily detect tokens generated by a given program. + * + * You can learn more about appending checksum to a hash here in this Github + * article. https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ + * + * Code taken from: + * https://github.com/tsxper/crc32/blob/main/src/CRC32.ts + */ + +export class CRC32 { + /** + * Lookup table calculated for 0xEDB88320 divisor + */ + #lookupTable = [ + 0, 1996959894, 3993919788, 2567524794, 124634137, 1886057615, 3915621685, 2657392035, 249268274, + 2044508324, 3772115230, 2547177864, 162941995, 2125561021, 3887607047, 2428444049, 498536548, + 1789927666, 4089016648, 2227061214, 450548861, 1843258603, 4107580753, 2211677639, 325883990, + 1684777152, 4251122042, 2321926636, 335633487, 1661365465, 4195302755, 2366115317, 997073096, + 1281953886, 3579855332, 2724688242, 1006888145, 1258607687, 3524101629, 2768942443, 901097722, + 1119000684, 3686517206, 2898065728, 853044451, 1172266101, 3705015759, 2882616665, 651767980, + 1373503546, 3369554304, 3218104598, 565507253, 1454621731, 3485111705, 3099436303, 671266974, + 1594198024, 3322730930, 2970347812, 795835527, 1483230225, 3244367275, 3060149565, 1994146192, + 31158534, 2563907772, 4023717930, 1907459465, 112637215, 2680153253, 3904427059, 2013776290, + 251722036, 2517215374, 3775830040, 2137656763, 141376813, 2439277719, 3865271297, 1802195444, + 476864866, 2238001368, 4066508878, 1812370925, 453092731, 2181625025, 4111451223, 1706088902, + 314042704, 2344532202, 4240017532, 1658658271, 366619977, 2362670323, 4224994405, 1303535960, + 984961486, 2747007092, 3569037538, 1256170817, 1037604311, 2765210733, 3554079995, 1131014506, + 879679996, 2909243462, 3663771856, 1141124467, 855842277, 2852801631, 3708648649, 1342533948, + 654459306, 3188396048, 3373015174, 1466479909, 544179635, 3110523913, 3462522015, 1591671054, + 702138776, 2966460450, 3352799412, 1504918807, 783551873, 3082640443, 3233442989, 3988292384, + 2596254646, 62317068, 1957810842, 3939845945, 2647816111, 81470997, 1943803523, 3814918930, + 2489596804, 225274430, 2053790376, 3826175755, 2466906013, 167816743, 2097651377, 4027552580, + 2265490386, 503444072, 1762050814, 4150417245, 2154129355, 426522225, 1852507879, 4275313526, + 2312317920, 282753626, 1742555852, 4189708143, 2394877945, 397917763, 1622183637, 3604390888, + 2714866558, 953729732, 1340076626, 3518719985, 2797360999, 1068828381, 1219638859, 3624741850, + 2936675148, 906185462, 1090812512, 3747672003, 2825379669, 829329135, 1181335161, 3412177804, + 3160834842, 628085408, 1382605366, 3423369109, 3138078467, 570562233, 1426400815, 3317316542, + 2998733608, 733239954, 1555261956, 3268935591, 3050360625, 752459403, 1541320221, 2607071920, + 3965973030, 1969922972, 40735498, 2617837225, 3943577151, 1913087877, 83908371, 2512341634, + 3803740692, 2075208622, 213261112, 2463272603, 3855990285, 2094854071, 198958881, 2262029012, + 4057260610, 1759359992, 534414190, 2176718541, 4139329115, 1873836001, 414664567, 2282248934, + 4279200368, 1711684554, 285281116, 2405801727, 4167216745, 1634467795, 376229701, 2685067896, + 3608007406, 1308918612, 956543938, 2808555105, 3495958263, 1231636301, 1047427035, 2932959818, + 3654703836, 1088359270, 936918000, 2847714899, 3736837829, 1202900863, 817233897, 3183342108, + 3401237130, 1404277552, 615818150, 3134207493, 3453421203, 1423857449, 601450431, 3009837614, + 3294710456, 1567103746, 711928724, 3020668471, 3272380065, 1510334235, 755167117, + ] + + #initialCRC = 0xffffffff + + #calculateBytes(bytes: Uint8Array, accumulator?: number): number { + let crc = accumulator || this.#initialCRC + for (const byte of bytes) { + const tableIndex = (crc ^ byte) & 0xff + const tableVal = this.#lookupTable[tableIndex] as number + crc = (crc >>> 8) ^ tableVal + } + return crc + } + + #crcToUint(crc: number): number { + return this.#toUint32(crc ^ 0xffffffff) + } + + #strToBytes(input: string): Uint8Array { + const encoder = new TextEncoder() + return encoder.encode(input) + } + + #toUint32(num: number): number { + if (num >= 0) { + return num + } + return 0xffffffff - num * -1 + 1 + } + + calculate(input: string): number { + return this.forString(input) + } + + forString(input: string): number { + const bytes = this.#strToBytes(input) + return this.forBytes(bytes) + } + + forBytes(bytes: Uint8Array, accumulator?: number): number { + const crc = this.#calculateBytes(bytes, accumulator) + return this.#crcToUint(crc) + } +} diff --git a/modules/access_tokens_guard/guard.ts b/modules/access_tokens_guard/guard.ts new file mode 100644 index 0000000..50080c1 --- /dev/null +++ b/modules/access_tokens_guard/guard.ts @@ -0,0 +1,229 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Secret } from '@adonisjs/core/helpers' +import type { HttpContext } from '@adonisjs/core/http' +import type { EmitterLike } from '@adonisjs/core/types/events' + +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import type { AuthClientResponse, GuardContract } from '../../src/types.js' +import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' +import type { AccessTokensGuardEvents, AccessTokensUserProviderContract } from './types.js' + +/** + * Implementation of access tokens guard for the Auth layer. The heavy lifting + * of verifying tokens is done by the user provider. However, the guard is + * used to seamlessly integrate with the auth layer of the package. + */ +export class AccessTokensGuard> + implements GuardContract +{ + /** + * Events emitted by the guard + */ + declare [GUARD_KNOWN_EVENTS]: AccessTokensGuardEvents + + /** + * A unique name for the guard. + */ + #name: string + + /** + * Reference to the current HTTP context + */ + #ctx: HttpContext + + /** + * Provider to lookup user details + */ + #userProvider: UserProvider + + /** + * Emitter to emit events + */ + #emitter: EmitterLike> + + /** + * Driver name of the guard + */ + driverName: 'access_tokens' = 'access_tokens' + + /** + * Whether or not the authentication has been attempted + * during the current request. + */ + authenticationAttempted = false + + /** + * A boolean to know if the current request has + * been authenticated + */ + isAuthenticated = false + + /** + * Reference to an instance of the authenticated user. + * The value only exists after calling one of the + * following methods. + * + * - authenticate + * - check + * + * You can use the "getUserOrFail" method to throw an exception if + * the request is not authenticated. + */ + user?: UserProvider[typeof PROVIDER_REAL_USER] + + constructor( + name: string, + ctx: HttpContext, + emitter: EmitterLike>, + userProvider: UserProvider + ) { + this.#name = name + this.#ctx = ctx + this.#emitter = emitter + this.#userProvider = userProvider + } + + /** + * Emits authentication failure and returns an exception + * to end the authentication cycle. + */ + #authenticationFailed() { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + + this.#emitter.emit('access_tokens_auth:authentication_failed', { + ctx: this.#ctx, + guardName: this.#name, + error, + }) + + return error + } + + /** + * Returns the bearer token from the request headers or fails + */ + #getBearerToken(): string { + const bearerToken = this.#ctx.request.header('authorization', '')! + const [, token] = bearerToken.split('Bearer ') + if (!token) { + throw this.#authenticationFailed() + } + + return token + } + + /** + * Returns an instance of the authenticated user. Or throws + * an exception if the request is not authenticated. + */ + getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { + if (!this.user) { + throw new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + } + + return this.user + } + + /** + * Authenticate the current HTTP request by verifying the bearer + * token or fails with an exception + */ + async authenticate(): Promise { + /** + * Return early when authentication has already + * been attempted + */ + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + + /** + * Notify we begin to attempt the authentication + */ + this.authenticationAttempted = true + this.#emitter.emit('access_tokens_auth:authentication_attempted', { + ctx: this.#ctx, + guardName: this.#name, + }) + + /** + * Decode token or fail when unable to do so + */ + const bearerToken = new Secret(this.#getBearerToken()) + + /** + * Verify for token via the user provider + */ + const token = await this.#userProvider.verifyToken(bearerToken) + if (!token) { + throw this.#authenticationFailed() + } + + /** + * Check if a user for the token exists. Otherwise abort + * authentication + */ + const providerUser = await this.#userProvider.findById(token.tokenableId) + if (!providerUser) { + throw this.#authenticationFailed() + } + + /** + * Update local state + */ + this.isAuthenticated = true + this.user = providerUser.getOriginal() + + /** + * Notify + */ + this.#emitter.emit('access_tokens_auth:authentication_succeeded', { + ctx: this.#ctx, + token, + guardName: this.#name, + user: this.user, + }) + + return this.user + } + + /** + * Returns the Authorization header clients can use to authenticate + * the request. + */ + async authenticateAsClient( + _: UserProvider[typeof PROVIDER_REAL_USER] + ): Promise { + throw new Error('Not supported') + } + + /** + * Silently check if the user is authenticated or not. The + * method is same the "authenticate" method but does not + * throw any exceptions. + */ + async check(): Promise { + try { + await this.authenticate() + return true + } catch (error) { + if (error instanceof E_UNAUTHORIZED_ACCESS) { + return false + } + + throw error + } + } +} diff --git a/modules/access_tokens_guard/token_providers/db.ts b/modules/access_tokens_guard/token_providers/db.ts new file mode 100644 index 0000000..10d30b0 --- /dev/null +++ b/modules/access_tokens_guard/token_providers/db.ts @@ -0,0 +1,224 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Secret } from '@adonisjs/core/helpers' +import type { LucidModel } from '@adonisjs/lucid/types/model' + +import { AccessToken } from '../access_token.js' +import type { + AccessTokenDbColumns, + AccessTokensProviderContract, + DbAccessTokensProviderOptions, +} from '../types.js' + +/** + * DbAccessTokensProvider uses lucid database service to fetch and + * persist tokens for a given user. + * + * The user must be an instance of the associated user model. + */ +export class DbAccessTokensProvider + implements AccessTokensProviderContract +{ + /** + * Create tokens provider instance for a given Lucid model + */ + static forModel( + model: DbAccessTokensProviderOptions['tokenableModel'], + options?: Omit, 'tokenableModel'> + ) { + return new DbAccessTokensProvider({ tokenableModel: model, ...(options || {}) }) + } + + /** + * A unique type for the value. The type is used to identify a + * bucket of tokens within the storage layer. + * + * Defaults to auth_token + */ + protected type: string + + /** + * A unique prefix to append to the publicly shared token value. + * + * Defaults to oat + */ + protected prefix: string + + /** + * Database table to use for querying access tokens + */ + protected table: string + + /** + * The length for the token secret. A secret is a cryptographically + * secure random string. + */ + protected tokenSecretLength: number + + constructor(protected options: DbAccessTokensProviderOptions) { + this.table = options.table || 'auth_access_tokens' + this.tokenSecretLength = options.tokenSecretLength || 40 + this.type = options.type || 'auth_token' + this.prefix = options.prefix || 'oat' + } + + protected async getDb() { + const model = this.options.tokenableModel + return model.$adapter.query(model).client + } + + /** + * Create a token for a user + */ + async create( + user: InstanceType, + abilities: string[] = ['*'], + expiresIn?: string | number + ) { + const queryClient = await this.getDb() + const transientToken = AccessToken.createTransientToken( + user.$primaryKeyValue!, + this.tokenSecretLength, + expiresIn || this.options.expiresIn + ) + + const dbRow: Omit = { + tokenable_id: transientToken.userId, + type: this.type, + hash: transientToken.hash, + abilities: JSON.stringify(abilities), + created_at: new Date(), + updated_at: new Date(), + last_used_at: null, + expires_at: transientToken.expiresAt || null, + } + + const [id] = await queryClient.table(this.table).insert(dbRow) + return new AccessToken({ + identifier: id, + tokenableId: dbRow.tokenable_id, + type: dbRow.type, + prefix: this.prefix, + secret: transientToken.secret, + hash: dbRow.hash, + abilities: JSON.parse(dbRow.abilities), + createdAt: dbRow.created_at, + updatedAt: dbRow.updated_at, + lastUsedAt: dbRow.last_used_at, + expiresAt: dbRow.expires_at, + }) + } + + /** + * Find a token for a user by the token id + */ + async find(user: InstanceType, identifier: string) { + const queryClient = await this.getDb() + const dbRow = await queryClient + .query() + .from(this.table) + .where({ id: identifier, tokenable_id: user.$primaryKeyValue, type: this.type }) + .limit(1) + .first() + + if (!dbRow) { + return null + } + + return new AccessToken({ + identifier: dbRow.id, + tokenableId: dbRow.tokenable_id, + type: dbRow.type, + hash: dbRow.hash, + abilities: JSON.parse(dbRow.abilities), + createdAt: dbRow.created_at, + updatedAt: dbRow.updated_at, + lastUsedAt: dbRow.last_used_at, + expiresAt: dbRow.expires_at, + }) + } + + /** + * Returns all the tokens a given user + */ + async all(user: InstanceType) { + const queryClient = await this.getDb() + const dbRows = await queryClient + .query() + .from(this.table) + .where({ tokenable_id: user.$primaryKeyValue, type: this.type }) + .exec() + + if (!dbRows) { + return null + } + + return dbRows.map((dbRow) => { + return new AccessToken({ + identifier: dbRow.id, + tokenableId: dbRow.tokenable_id, + type: dbRow.type, + hash: dbRow.hash, + abilities: JSON.parse(dbRow.abilities), + createdAt: dbRow.created_at, + updatedAt: dbRow.updated_at, + lastUsedAt: dbRow.last_used_at, + expiresAt: dbRow.expires_at, + }) + }) + } + + /** + * Verifies a publicly shared access token and returns an + * access token for it. + * + * Returns null when unable to verify the token or find it + * inside the storage + */ + async verify(tokenValue: Secret) { + const decodedToken = AccessToken.decode(this.prefix, tokenValue.release()) + if (!decodedToken) { + return null + } + + const db = await this.getDb() + const dbRow = await db + .query() + .from(this.table) + .where({ id: decodedToken.identifier, type: this.type }) + .limit(1) + .first() + + if (!dbRow) { + return null + } + + const accessToken = new AccessToken({ + identifier: dbRow.id, + tokenableId: dbRow.tokenable_id, + type: dbRow.type, + hash: dbRow.hash, + abilities: JSON.parse(dbRow.abilities), + createdAt: dbRow.created_at, + updatedAt: dbRow.updated_at, + lastUsedAt: dbRow.last_used_at, + expiresAt: dbRow.expires_at, + }) + + /** + * Ensure the token secret matches the token hash + */ + if (!accessToken.verify(decodedToken.secret) || accessToken.isExpired()) { + return null + } + + return accessToken + } +} diff --git a/modules/access_tokens_guard/types.ts b/modules/access_tokens_guard/types.ts new file mode 100644 index 0000000..1d158a3 --- /dev/null +++ b/modules/access_tokens_guard/types.ts @@ -0,0 +1,226 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Secret } from '@adonisjs/core/helpers' +import type { HttpContext } from '@adonisjs/core/http' +import type { Exception } from '@adonisjs/core/exceptions' +import type { LucidModel } from '@adonisjs/lucid/types/model' + +import type { AccessToken } from './access_token.js' +import type { PROVIDER_REAL_USER } from '../../src/symbols.js' + +/** + * Options accepted by the tokens provider that uses lucid + * database service to fetch and persist tokens. + */ +export type DbAccessTokensProviderOptions = { + /** + * The user model for which to generate tokens. Note, the model + * is not used for tokens, but is used to associate a user + * with the token + */ + tokenableModel: TokenableModel + + /** + * Database table to use for querying tokens. + * + * Defaults to "auth_access_tokens" + */ + table?: string + + /** + * The default expiry for all the tokens. You can also customize + * expiry at the time of creating a token as well. + * + * By default tokens do not expire + */ + expiresIn?: string | number + + /** + * The length for the token secret. A secret is a cryptographically + * secure random string. + * + * Defaults to 40 + */ + tokenSecretLength?: number + + /** + * A unique type for the value. The type is used to identify a + * bucket of tokens within the storage layer. + * + * Defaults to auth_token + */ + type?: string + + /** + * A unique prefix to append to the publicly shared token value. + * + * Defaults to oat + */ + prefix?: string +} + +/** + * The database columns expected at the database level + */ +export type AccessTokenDbColumns = { + /** + * Token primary key. It can be an integer, bigInteger or + * even a UUID or any other string based value. + * + * The id should not have ". (dots)" inside it. + */ + id: number | string | BigInt + + /** + * The user or entity for whom the token is + * generated + */ + tokenable_id: string | number | BigInt + + /** + * A unique type for the token. It is used to + * unique identify tokens within the storage + * layer. + */ + type: string + + /** + * Token hash is used to verify the token shared + * with the user + */ + hash: string + + /** + * Timestamps + */ + created_at: Date + updated_at: Date + + /** + * An array of abilities stored as JSON. + */ + abilities: string + + /** + * The date after which the token will be considered + * expired. + * + * A null value means the token is long-lived + */ + expires_at: null | Date + + /** + * Last time the token was used for authentication + */ + last_used_at: null | Date +} + +/** + * Access token providers are used verify an access token + * during authentication + */ +export interface AccessTokensProviderContract { + /** + * Verifies a publicly shared access token and returns an + * access token for it. + */ + verify(tokenValue: Secret): Promise +} + +/** + * A lucid model with a tokens provider to verify tokens during + * authentication + */ +export type LucidTokenable = LucidModel & { + [K in TokenableProperty]: AccessTokensProviderContract +} + +/** + * Options accepted by the user provider that uses a lucid + * model to lookup a user during authentication and verify + * tokens + */ +export type AccessTokensLucidUserProviderOptions< + TokenableProperty extends string, + Model extends LucidTokenable, +> = { + tokens: TokenableProperty + model: () => Promise<{ default: Model }> +} + +/** + * Guard user is an adapter between the user provider + * and the guard. + * + * The guard is user provider agnostic and therefore it + * needs a adapter to known some basic info about the + * user. + */ +export type AccessTokensGuardUser = { + getId(): string | number | BigInt + getOriginal(): RealUser +} + +/** + * The user provider used by access tokens guard to lookup + * users and verify tokens. + */ +export interface AccessTokensUserProviderContract { + [PROVIDER_REAL_USER]: RealUser + + /** + * Create a user object that acts as an adapter between + * the guard and real user value. + */ + createUserForGuard(user: RealUser): Promise> + + /** + * Find a user by their id. + */ + findById(tokenableId: string | number | BigInt): Promise | null> + + /** + * Verify a token by its publicly shared value. + */ + verifyToken(tokenValue: Secret): Promise +} + +/** + * Events emitted by the access tokens guard during + * authentication + */ +export type AccessTokensGuardEvents = { + /** + * Attempting to authenticate the user + */ + 'access_tokens_auth:authentication_attempted': { + ctx: HttpContext + guardName: string + } + + /** + * Authentication was successful + */ + 'access_tokens_auth:authentication_succeeded': { + ctx: HttpContext + guardName: string + user: RealUser + token: AccessToken + } + + /** + * Authentication failed + */ + 'access_tokens_auth:authentication_failed': { + ctx: HttpContext + guardName: string + error: Exception + } +} diff --git a/modules/access_tokens_guard/user_providers/lucid.ts b/modules/access_tokens_guard/user_providers/lucid.ts new file mode 100644 index 0000000..420e38b --- /dev/null +++ b/modules/access_tokens_guard/user_providers/lucid.ts @@ -0,0 +1,133 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { RuntimeException } from '@adonisjs/core/exceptions' +import { PROVIDER_REAL_USER } from '../../../src/symbols.js' +import { + AccessTokensGuardUser, + AccessTokensLucidUserProviderOptions, + AccessTokensUserProviderContract, + LucidTokenable, +} from '../types.js' +import { Secret } from '@adonisjs/core/helpers' +import { AccessToken } from '../access_token.js' + +/** + * Uses a lucid model to verify access tokens and find a user during + * authentication + */ +export class AccessTokensLucidUserProvider< + TokensProperty extends string, + UserModel extends LucidTokenable, +> implements AccessTokensUserProviderContract> +{ + declare [PROVIDER_REAL_USER]: InstanceType + + /** + * Reference to the lazily imported model + */ + protected model?: UserModel + + constructor( + /** + * Lucid provider options + */ + protected options: AccessTokensLucidUserProviderOptions + ) {} + + /** + * Imports the model from the provider, returns and caches it + * for further operations. + */ + protected async getModel() { + if (this.model) { + return this.model + } + + const importedModel = await this.options.model() + this.model = importedModel.default + return this.model + } + + /** + * Returns the tokens provider associated with the user model + */ + protected async getTokensProvider() { + const model = await this.getModel() + + if (!model[this.options.tokens]) { + throw new RuntimeException( + `Cannot use "${model.name}" for verifying access tokens. Make sure to assign a token provider to the model.` + ) + } + + return model[this.options.tokens] + } + + /** + * Creates an adapter user for the guard + */ + async createUserForGuard( + user: InstanceType + ): Promise>> { + const model = await this.getModel() + + /** + * Ensure user is an instance of the model + */ + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + return { + getId() { + /** + * Ensure user has a primary key + */ + if (!user.$primaryKeyValue) { + throw new RuntimeException( + `Cannot use "${model.name}" model for authentication. The value of column "${model.primaryKey}" is undefined or null` + ) + } + + return user.$primaryKeyValue + }, + getOriginal() { + return user + }, + } + } + + /** + * Finds a user by their primary key value + */ + async findById( + tokenableId: string | number | BigInt + ): Promise> | null> { + const model = await this.getModel() + const user = await model.find(tokenableId) + + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + /** + * Verifies a publicly shared access token and returns an + * access token for it. + */ + async verifyToken(tokenValue: Secret): Promise { + const tokensProvider = await this.getTokensProvider() + return tokensProvider.verify(tokenValue) + } +} diff --git a/tests/access_tokens/guard/authenticate.spec.ts b/tests/access_tokens/guard/authenticate.spec.ts new file mode 100644 index 0000000..8edbb5b --- /dev/null +++ b/tests/access_tokens/guard/authenticate.spec.ts @@ -0,0 +1,335 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { E_UNAUTHORIZED_ACCESS } from '../../../src/errors.js' +import { createEmitter, pEvent, timeTravel } from '../../helpers.js' +import { AccessTokensGuard } from '../../../modules/access_tokens_guard/guard.js' +import type { AccessTokensGuardEvents } from '../../../modules/access_tokens_guard/types.js' +import { + type AccessTokensFakeUser, + AccessTokensFakeUserProvider, +} from '../../../factories/access_tokens/main.js' + +test.group('Access tokens guard | authenticate', () => { + test('return user when access token is valid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new AccessTokensFakeUserProvider() + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const user = await userProvider.findById(1) + const token = await userProvider.createToken(user!.getOriginal()) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const [attempted, succeeded, authenticatedUser] = await Promise.all([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_succeeded'), + guard.authenticate(), + ]) + + assert.equal(attempted!.guardName, 'api') + assert.equal(succeeded!.guardName, 'api') + assert.equal(succeeded!.token.identifier, token.identifier) + + assert.deepEqual(guard.user, authenticatedUser) + assert.deepEqual(guard.getUserOrFail(), authenticatedUser) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when authorization header is missing', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + + const [attempted, failed, authenticationResult] = await Promise.allSettled([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_failed'), + guard.authenticate(), + ]) + + assert.equal(attempted!.status, 'fulfilled') + assert.equal(failed!.status, 'fulfilled') + if (failed!.status === 'fulfilled') { + assert.equal(failed!.value.error.message, 'Unauthorized access') + } + + assert.equal(authenticationResult!.status, 'rejected') + if (authenticationResult!.status === 'rejected') { + assert.instanceOf(authenticationResult!.reason, E_UNAUTHORIZED_ACCESS) + } + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when authorization header does not have a bearer token', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + ctx.request.request.headers.authorization = 'foo bar' + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const [attempted, failed, authenticationResult] = await Promise.allSettled([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_failed'), + guard.authenticate(), + ]) + + assert.equal(attempted!.status, 'fulfilled') + assert.equal(failed!.status, 'fulfilled') + if (failed!.status === 'fulfilled') { + assert.equal(failed!.value.error.message, 'Unauthorized access') + } + + assert.equal(authenticationResult!.status, 'rejected') + if (authenticationResult!.status === 'rejected') { + assert.instanceOf(authenticationResult!.reason, E_UNAUTHORIZED_ACCESS) + } + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when authorization header has an empty bearer token', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + ctx.request.request.headers.authorization = 'Bearer ' + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const [attempted, failed, authenticationResult] = await Promise.allSettled([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_failed'), + guard.authenticate(), + ]) + + assert.equal(attempted!.status, 'fulfilled') + assert.equal(failed!.status, 'fulfilled') + if (failed!.status === 'fulfilled') { + assert.equal(failed!.value.error.message, 'Unauthorized access') + } + + assert.equal(authenticationResult!.status, 'rejected') + if (authenticationResult!.status === 'rejected') { + assert.instanceOf(authenticationResult!.reason, E_UNAUTHORIZED_ACCESS) + } + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when bearer token is invalid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + ctx.request.request.headers.authorization = 'Bearer helloworld' + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const [attempted, failed, authenticationResult] = await Promise.allSettled([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_failed'), + guard.authenticate(), + ]) + + assert.equal(attempted!.status, 'fulfilled') + assert.equal(failed!.status, 'fulfilled') + if (failed!.status === 'fulfilled') { + assert.equal(failed!.value.error.message, 'Unauthorized access') + } + + assert.equal(authenticationResult!.status, 'rejected') + if (authenticationResult!.status === 'rejected') { + assert.instanceOf(authenticationResult!.reason, E_UNAUTHORIZED_ACCESS) + } + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when bearer token does not exist', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + const user = await userProvider.findById(1) + const token = await userProvider.createToken(user!.getOriginal()) + userProvider.deleteToken(token.identifier) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const [attempted, failed, authenticationResult] = await Promise.allSettled([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_failed'), + guard.authenticate(), + ]) + + assert.equal(attempted!.status, 'fulfilled') + assert.equal(failed!.status, 'fulfilled') + if (failed!.status === 'fulfilled') { + assert.equal(failed!.value.error.message, 'Unauthorized access') + } + + assert.equal(authenticationResult!.status, 'rejected') + if (authenticationResult!.status === 'rejected') { + assert.instanceOf(authenticationResult!.reason, E_UNAUTHORIZED_ACCESS) + } + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when token user is missing', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + const user = await userProvider.findById(1) + const originalUser = { ...user!.getOriginal() } + + /** + * Mutating id to point to a non-existing user + */ + originalUser.id = 10 + + const token = await userProvider.createToken(originalUser) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const [attempted, failed, authenticationResult] = await Promise.allSettled([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_failed'), + guard.authenticate(), + ]) + + assert.equal(attempted!.status, 'fulfilled') + assert.equal(failed!.status, 'fulfilled') + if (failed!.status === 'fulfilled') { + assert.equal(failed!.value.error.message, 'Unauthorized access') + } + + assert.equal(authenticationResult!.status, 'rejected') + if (authenticationResult!.status === 'rejected') { + assert.instanceOf(authenticationResult!.reason, E_UNAUTHORIZED_ACCESS) + } + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('throw error when bearer token has been expired', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + const user = await userProvider.findById(1) + const token = await userProvider.createToken(user!.getOriginal(), '20 mins') + timeTravel(21 * 60) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const [attempted, failed, authenticationResult] = await Promise.allSettled([ + pEvent(emitter, 'access_tokens_auth:authentication_attempted'), + pEvent(emitter, 'access_tokens_auth:authentication_failed'), + guard.authenticate(), + ]) + + assert.equal(attempted!.status, 'fulfilled') + assert.equal(failed!.status, 'fulfilled') + if (failed!.status === 'fulfilled') { + assert.equal(failed!.value.error.message, 'Unauthorized access') + } + + assert.equal(authenticationResult!.status, 'rejected') + if (authenticationResult!.status === 'rejected') { + assert.instanceOf(authenticationResult!.reason, E_UNAUTHORIZED_ACCESS) + } + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Unauthorized access') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('multiple calls to authenticate method should be a noop', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const user = await userProvider.findById(1) + const token = await userProvider.createToken(user!.getOriginal(), '20 mins') + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + + /** + * Even though the token exists now, the authenticate + * method will use previous state + */ + await assert.rejects(() => guard.authenticate(), 'Unauthorized access') + + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) +}) + +test.group('Access token guard | check', () => { + test('return true when access token is valid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const user = await userProvider.findById(1) + const token = await userProvider.createToken(user!.getOriginal()) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const isLoggedIn = await guard.check() + + assert.isTrue(isLoggedIn) + assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) + + test('return false when access token is invalid', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter() + const userProvider = new AccessTokensFakeUserProvider() + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const user = await userProvider.findById(1) + const token = await userProvider.createToken(user!.getOriginal()) + userProvider.deleteToken(token.identifier) + + ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` + const isLoggedIn = await guard.check() + + assert.isFalse(isLoggedIn) + assert.isUndefined(guard.user) + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 34a9583..261b15c 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -19,14 +19,10 @@ import { Database } from '@adonisjs/lucid/database' import { Encryption } from '@adonisjs/core/encryption' import { Scrypt } from '@adonisjs/hash/drivers/scrypt' import { AppFactory } from '@adonisjs/core/factories/app' -import setCookieParser, { CookieMap } from 'set-cookie-parser' import { LoggerFactory } from '@adonisjs/core/factories/logger' +import setCookieParser, { type CookieMap } from 'set-cookie-parser' import { EncryptionFactory } from '@adonisjs/core/factories/encryption' -// import { SessionGuardEvents } from '../modules/session_guard/types.js' -// import { FactoryUser } from '../backup/factories/core/lucid_user_provider.js' -// import { AccessTokenGuardEvents } from '../modules/access_token_guard/types.js' - export const encryption: Encryption = new EncryptionFactory().create() /** @@ -114,15 +110,15 @@ export async function createTables(db: Database) { * Creates an emitter instance for testing with typed * events */ -// export function createEmitter() { -// const test = getActiveTest() -// if (!test) { -// throw new Error('Cannot use "createEmitter" outside of a Japa test') -// } - -// const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) -// return new Emitter & AccessTokenGuardEvents>(app) -// } +export function createEmitter>() { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "createEmitter" outside of a Japa test') + } + + const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) + return new Emitter(app) +} /** * Promisify an event From ec09d6eb77cf61ce4d31f4efb26b62bd1e34dfd8 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 15 Jan 2024 12:51:53 +0530 Subject: [PATCH 72/96] feat: implement db access tokens provider --- .../access_tokens_guard/token_providers/db.ts | 126 ++-- modules/access_tokens_guard/types.ts | 2 +- .../access_tokens/token_providers/db.spec.ts | 642 ++++++++++++++++++ tests/helpers.ts | 14 +- 4 files changed, 740 insertions(+), 44 deletions(-) create mode 100644 tests/access_tokens/token_providers/db.spec.ts diff --git a/modules/access_tokens_guard/token_providers/db.ts b/modules/access_tokens_guard/token_providers/db.ts index 10d30b0..3d45f90 100644 --- a/modules/access_tokens_guard/token_providers/db.ts +++ b/modules/access_tokens_guard/token_providers/db.ts @@ -16,6 +16,7 @@ import type { AccessTokensProviderContract, DbAccessTokensProviderOptions, } from '../types.js' +import { RuntimeException } from '@adonisjs/core/exceptions' /** * DbAccessTokensProvider uses lucid database service to fetch and @@ -66,9 +67,33 @@ export class DbAccessTokensProvider this.table = options.table || 'auth_access_tokens' this.tokenSecretLength = options.tokenSecretLength || 40 this.type = options.type || 'auth_token' - this.prefix = options.prefix || 'oat' + this.prefix = options.prefix || 'oat_' } + /** + * Maps a database row to an instance token instance + */ + protected dbRowToAccessToken(dbRow: AccessTokenDbColumns): AccessToken { + return new AccessToken({ + identifier: dbRow.id, + tokenableId: dbRow.tokenable_id, + type: dbRow.type, + hash: dbRow.hash, + abilities: JSON.parse(dbRow.abilities), + createdAt: + typeof dbRow.created_at === 'number' ? new Date(dbRow.created_at) : dbRow.created_at, + updatedAt: + typeof dbRow.updated_at === 'number' ? new Date(dbRow.updated_at) : dbRow.updated_at, + lastUsedAt: + typeof dbRow.last_used_at === 'number' ? new Date(dbRow.last_used_at) : dbRow.last_used_at, + expiresAt: + typeof dbRow.expires_at === 'number' ? new Date(dbRow.expires_at) : dbRow.expires_at, + }) + } + + /** + * Returns a query client instance from the parent model + */ protected async getDb() { const model = this.options.tokenableModel return model.$adapter.query(model).client @@ -82,13 +107,31 @@ export class DbAccessTokensProvider abilities: string[] = ['*'], expiresIn?: string | number ) { + const model = this.options.tokenableModel const queryClient = await this.getDb() + const tokenableId = user.$primaryKeyValue + + if (!tokenableId) { + throw new RuntimeException( + `Cannot generate access token for "${model.name}" model. The value of "${model.primaryKey}" is undefined or null` + ) + } + + /** + * Creating a transient token. Transient token abstracts + * the logic of creating a random secure secret and its + * hash + */ const transientToken = AccessToken.createTransientToken( user.$primaryKeyValue!, this.tokenSecretLength, expiresIn || this.options.expiresIn ) + /** + * Row to insert inside the database. We expect exactly these + * columns to exist. + */ const dbRow: Omit = { tokenable_id: transientToken.userId, type: this.type, @@ -100,7 +143,14 @@ export class DbAccessTokensProvider expires_at: transientToken.expiresAt || null, } + /** + * Insert data to the database. + */ const [id] = await queryClient.table(this.table).insert(dbRow) + + /** + * Convert db row to an access token + */ return new AccessToken({ identifier: id, tokenableId: dbRow.tokenable_id, @@ -119,7 +169,7 @@ export class DbAccessTokensProvider /** * Find a token for a user by the token id */ - async find(user: InstanceType, identifier: string) { + async find(user: InstanceType, identifier: string | number | BigInt) { const queryClient = await this.getDb() const dbRow = await queryClient .query() @@ -132,17 +182,25 @@ export class DbAccessTokensProvider return null } - return new AccessToken({ - identifier: dbRow.id, - tokenableId: dbRow.tokenable_id, - type: dbRow.type, - hash: dbRow.hash, - abilities: JSON.parse(dbRow.abilities), - createdAt: dbRow.created_at, - updatedAt: dbRow.updated_at, - lastUsedAt: dbRow.last_used_at, - expiresAt: dbRow.expires_at, - }) + return this.dbRowToAccessToken(dbRow) + } + + /** + * Delete a token by its id + */ + async delete( + user: InstanceType, + identifier: string | number | BigInt + ): Promise { + const queryClient = await this.getDb() + const affectedRows = await queryClient + .query() + .from(this.table) + .where({ id: identifier, tokenable_id: user.$primaryKeyValue, type: this.type }) + .del() + .exec() + + return affectedRows as unknown as number } /** @@ -154,24 +212,12 @@ export class DbAccessTokensProvider .query() .from(this.table) .where({ tokenable_id: user.$primaryKeyValue, type: this.type }) + .orderBy('last_used_at', 'desc') + .orderBy('id', 'desc') .exec() - if (!dbRows) { - return null - } - return dbRows.map((dbRow) => { - return new AccessToken({ - identifier: dbRow.id, - tokenableId: dbRow.tokenable_id, - type: dbRow.type, - hash: dbRow.hash, - abilities: JSON.parse(dbRow.abilities), - createdAt: dbRow.created_at, - updatedAt: dbRow.updated_at, - lastUsedAt: dbRow.last_used_at, - expiresAt: dbRow.expires_at, - }) + return this.dbRowToAccessToken(dbRow) }) } @@ -200,17 +246,19 @@ export class DbAccessTokensProvider return null } - const accessToken = new AccessToken({ - identifier: dbRow.id, - tokenableId: dbRow.tokenable_id, - type: dbRow.type, - hash: dbRow.hash, - abilities: JSON.parse(dbRow.abilities), - createdAt: dbRow.created_at, - updatedAt: dbRow.updated_at, - lastUsedAt: dbRow.last_used_at, - expiresAt: dbRow.expires_at, - }) + /** + * Update last time the token is used + */ + dbRow.last_used_at = new Date() + await db + .from(this.table) + .where({ id: dbRow.id, type: dbRow.type }) + .update({ last_used_at: dbRow.last_used_at }) + + /** + * Convert to access token instance + */ + const accessToken = this.dbRowToAccessToken(dbRow) /** * Ensure the token secret matches the token hash diff --git a/modules/access_tokens_guard/types.ts b/modules/access_tokens_guard/types.ts index 1d158a3..81a356f 100644 --- a/modules/access_tokens_guard/types.ts +++ b/modules/access_tokens_guard/types.ts @@ -61,7 +61,7 @@ export type DbAccessTokensProviderOptions = { /** * A unique prefix to append to the publicly shared token value. * - * Defaults to oat + * Defaults to oat_ */ prefix?: string } diff --git a/tests/access_tokens/token_providers/db.spec.ts b/tests/access_tokens/token_providers/db.spec.ts new file mode 100644 index 0000000..d870f8f --- /dev/null +++ b/tests/access_tokens/token_providers/db.spec.ts @@ -0,0 +1,642 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Secret } from '@adonisjs/core/helpers' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +import { createDatabase, createTables, timeTravel } from '../../helpers.js' +import { AccessToken } from '../../../modules/access_tokens_guard/access_token.js' +import { DbAccessTokensProvider } from '../../../modules/access_tokens_guard/token_providers/db.js' + +test.group('Access tokens provider | DB | create', () => { + test('create token for a user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + assert.exists(token.identifier) + assert.instanceOf(token, AccessToken) + assert.equal(token.tokenableId, user.id) + assert.deepEqual(token.abilities, ['*']) + assert.isNull(token.lastUsedAt) + assert.isNull(token.expiresAt) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.equal(token.type, 'auth_token') + assert.isTrue(token.value!.release().startsWith('oat_')) + }) + + test('define token expiry', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user, ['*'], '20 mins') + assert.exists(token.identifier) + assert.instanceOf(token, AccessToken) + assert.equal(token.tokenableId, user.id) + assert.deepEqual(token.abilities, ['*']) + assert.isNull(token.lastUsedAt) + assert.instanceOf(token.expiresAt, Date) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.equal(token.type, 'auth_token') + assert.isTrue(token.value!.release().startsWith('oat_')) + + assert.isFalse(token.isExpired()) + timeTravel(21 * 60) + assert.isTrue(token.isExpired()) + }) + + test('customize token type', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User, { + type: 'oat', + }) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + assert.exists(token.identifier) + assert.instanceOf(token, AccessToken) + assert.equal(token.tokenableId, user.id) + assert.deepEqual(token.abilities, ['*']) + assert.isNull(token.lastUsedAt) + assert.isNull(token.expiresAt) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.equal(token.type, 'oat') + assert.isTrue(token.value!.release().startsWith('oat_')) + }) + + test('customize token prefix', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User, { + prefix: 'pat_', + }) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + assert.exists(token.identifier) + assert.instanceOf(token, AccessToken) + assert.equal(token.tokenableId, user.id) + assert.deepEqual(token.abilities, ['*']) + assert.isNull(token.lastUsedAt) + assert.isNull(token.expiresAt) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.isTrue(token.value!.release().startsWith('pat_')) + }) + + test('throw error when user id is missing', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = new User() + await assert.rejects( + () => User.authTokens.create(user), + 'Cannot generate access token for "User" model. The value of "id" is undefined or null' + ) + }) +}) + +test.group('Access tokens provider | DB | verify', () => { + test('return access token when token value is valid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + const freshToken = await User.authTokens.verify(new Secret(token.value!.release())) + + assert.instanceOf(freshToken, AccessToken) + assert.isUndefined(freshToken!.value) + assert.equal(freshToken!.type, token.type) + assert.equal(freshToken!.hash, token.hash) + assert.equal(freshToken!.createdAt.getTime(), token.createdAt.getTime()) + assert.instanceOf(freshToken!.lastUsedAt, Date) + }) + + test('return null when token has been expired', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user, ['*'], '20 mins') + timeTravel(21 * 60) + + const freshToken = await User.authTokens.verify(new Secret(token.value!.release())) + assert.isNull(freshToken) + }) + + test('return null when token does not exists', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + await User.authTokens.delete(user, token.identifier) + + const freshToken = await User.authTokens.verify(new Secret(token.value!.release())) + assert.isNull(freshToken) + }) + + test('return null when token type mis-matches', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + static personalTokens = DbAccessTokensProvider.forModel(User, { + type: 'personal_tokens', + }) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + + const freshToken = await User.personalTokens.verify(new Secret(token.value!.release())) + assert.isNull(freshToken) + }) + + test('return null when token value is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const freshToken = await User.authTokens.verify(new Secret('foo.bar')) + assert.isNull(freshToken) + }) + + test('return null when token secret is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + const value = token.value!.release() + const [identifier] = value.split('.') + + const freshToken = await User.authTokens.verify(new Secret(`${identifier}.bar`)) + assert.isNull(freshToken) + }) +}) + +test.group('Access tokens provider | DB | find', () => { + test('get token by id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + const freshToken = await User.authTokens.find(user, token.identifier) + + assert.exists(freshToken!.identifier) + assert.instanceOf(freshToken, AccessToken) + assert.equal(freshToken!.tokenableId, user.id) + assert.deepEqual(freshToken!.abilities, ['*']) + assert.isNull(freshToken!.lastUsedAt) + assert.isNull(freshToken!.expiresAt) + assert.instanceOf(freshToken!.createdAt, Date) + assert.instanceOf(freshToken!.updatedAt, Date) + assert.isDefined(freshToken!.hash) + assert.equal(freshToken!.type, 'auth_token') + assert.isUndefined(freshToken!.value) + }) + + test('get expired tokens as well', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user, ['*'], '20 mins') + timeTravel(21 * 60) + const freshToken = await User.authTokens.find(user, token.identifier) + + assert.exists(freshToken!.identifier) + assert.instanceOf(freshToken, AccessToken) + assert.equal(freshToken!.tokenableId, user.id) + assert.deepEqual(freshToken!.abilities, ['*']) + assert.isNull(freshToken!.lastUsedAt) + assert.instanceOf(freshToken!.expiresAt, Date) + assert.instanceOf(freshToken!.createdAt, Date) + assert.instanceOf(freshToken!.updatedAt, Date) + assert.isDefined(freshToken!.hash) + assert.equal(freshToken!.type, 'auth_token') + assert.isUndefined(freshToken!.value) + assert.isTrue(freshToken!.isExpired()) + }) + + test('get null when token is missing', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const freshToken = await User.authTokens.find(user, 'foo') + assert.isNull(freshToken) + }) +}) + +test.group('Access tokens provider | DB | all', () => { + test('get list of all tokens', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + await User.authTokens.create(user, ['*'], '20 mins') + await User.authTokens.create(user) + timeTravel(21 * 60) + const tokens = await User.authTokens.all(user) + + assert.lengthOf(tokens, 2) + + assert.exists(tokens[0].identifier) + assert.instanceOf(tokens[0], AccessToken) + assert.equal(tokens[0].tokenableId, user.id) + assert.deepEqual(tokens[0].abilities, ['*']) + assert.isNull(tokens[0].lastUsedAt) + assert.isNull(tokens[0].expiresAt) + assert.instanceOf(tokens[0].createdAt, Date) + assert.instanceOf(tokens[0].updatedAt, Date) + assert.isDefined(tokens[0].hash) + assert.equal(tokens[0].type, 'auth_token') + assert.isUndefined(tokens[0].value) + assert.isFalse(tokens[0].isExpired()) + + assert.exists(tokens[1].identifier) + assert.instanceOf(tokens[1], AccessToken) + assert.equal(tokens[1].tokenableId, user.id) + assert.deepEqual(tokens[1].abilities, ['*']) + assert.isNull(tokens[1].lastUsedAt) + assert.instanceOf(tokens[1].expiresAt, Date) + assert.instanceOf(tokens[1].createdAt, Date) + assert.instanceOf(tokens[1].updatedAt, Date) + assert.isDefined(tokens[1].hash) + assert.equal(tokens[1].type, 'auth_token') + assert.isUndefined(tokens[1].value) + assert.isTrue(tokens[1].isExpired()) + }) + + test('order tokens by last_used_at timestamp', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user, ['*'], '20 mins') + await User.authTokens.create(user) + + /** + * This will touch the last_used_at timestamp + */ + assert.instanceOf(await User.authTokens.verify(token.value!), AccessToken) + + const tokens = await User.authTokens.all(user) + + assert.lengthOf(tokens, 2) + + assert.equal(tokens[0].identifier, token.identifier) + assert.instanceOf(tokens[0], AccessToken) + assert.equal(tokens[0].tokenableId, user.id) + assert.deepEqual(tokens[0].abilities, ['*']) + assert.instanceOf(tokens[0].lastUsedAt, Date) + assert.instanceOf(tokens[0].expiresAt, Date) + assert.instanceOf(tokens[0].createdAt, Date) + assert.instanceOf(tokens[0].updatedAt, Date) + assert.isDefined(tokens[0].hash) + assert.equal(tokens[0].type, 'auth_token') + assert.isUndefined(tokens[0].value) + assert.isFalse(tokens[0].isExpired()) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 261b15c..5fa3554 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -79,14 +79,20 @@ export async function createTables(db: Database) { test.cleanup(async () => { await db.connection().schema.dropTable('users') - await db.connection().schema.dropTable('test_tokens') + await db.connection().schema.dropTable('auth_access_tokens') await db.connection().schema.dropTable('remember_me_tokens') }) - await db.connection().schema.createTable('test_tokens', (table) => { - table.string('series', 60).notNullable() - table.integer('user_id').notNullable().unsigned() + await db.connection().schema.createTable('auth_access_tokens', (table) => { + table.increments() + table.integer('tokenable_id').notNullable().unsigned() + table.integer('type').notNullable() table.string('hash', 80).notNullable() + table.json('abilities').notNullable() + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').notNullable() + table.timestamp('expires_at').nullable() + table.timestamp('last_used_at').nullable() }) await db.connection().schema.createTable('users', (table) => { From 263467f0fa4bd90e98c3a887b92a3a11f15fe23c Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 15 Jan 2024 13:22:46 +0530 Subject: [PATCH 73/96] feat: finish access tokens user provider --- .../access_tokens_guard/token_providers/db.ts | 35 ++- tests/access_tokens/access_token.spec.ts | 252 +++++++++++++++++ .../access_tokens/guard/authenticate.spec.ts | 12 + .../access_tokens/token_providers/db.spec.ts | 29 +- .../user_providers/lucid.spec.ts | 262 ++++++++++++++++++ 5 files changed, 581 insertions(+), 9 deletions(-) create mode 100644 tests/access_tokens/access_token.spec.ts create mode 100644 tests/access_tokens/user_providers/lucid.spec.ts diff --git a/modules/access_tokens_guard/token_providers/db.ts b/modules/access_tokens_guard/token_providers/db.ts index 3d45f90..942fa3f 100644 --- a/modules/access_tokens_guard/token_providers/db.ts +++ b/modules/access_tokens_guard/token_providers/db.ts @@ -70,6 +70,25 @@ export class DbAccessTokensProvider this.prefix = options.prefix || 'oat_' } + /** + * Ensure the provided user is an instance of the user model and + * has a primary key + */ + #ensureIsPersisted(user: InstanceType) { + const model = this.options.tokenableModel + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + if (!user.$primaryKeyValue) { + throw new RuntimeException( + `Cannot use "${model.name}" model for managing access tokens. The value of column "${model.primaryKey}" is undefined or null` + ) + } + } + /** * Maps a database row to an instance token instance */ @@ -107,15 +126,9 @@ export class DbAccessTokensProvider abilities: string[] = ['*'], expiresIn?: string | number ) { - const model = this.options.tokenableModel - const queryClient = await this.getDb() - const tokenableId = user.$primaryKeyValue + this.#ensureIsPersisted(user) - if (!tokenableId) { - throw new RuntimeException( - `Cannot generate access token for "${model.name}" model. The value of "${model.primaryKey}" is undefined or null` - ) - } + const queryClient = await this.getDb() /** * Creating a transient token. Transient token abstracts @@ -170,6 +183,8 @@ export class DbAccessTokensProvider * Find a token for a user by the token id */ async find(user: InstanceType, identifier: string | number | BigInt) { + this.#ensureIsPersisted(user) + const queryClient = await this.getDb() const dbRow = await queryClient .query() @@ -192,6 +207,8 @@ export class DbAccessTokensProvider user: InstanceType, identifier: string | number | BigInt ): Promise { + this.#ensureIsPersisted(user) + const queryClient = await this.getDb() const affectedRows = await queryClient .query() @@ -207,6 +224,8 @@ export class DbAccessTokensProvider * Returns all the tokens a given user */ async all(user: InstanceType) { + this.#ensureIsPersisted(user) + const queryClient = await this.getDb() const dbRows = await queryClient .query() diff --git a/tests/access_tokens/access_token.spec.ts b/tests/access_tokens/access_token.spec.ts new file mode 100644 index 0000000..5989a69 --- /dev/null +++ b/tests/access_tokens/access_token.spec.ts @@ -0,0 +1,252 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Secret, base64 } from '@poppinss/utils' + +import { freezeTime } from '../helpers.js' +import { AccessToken } from '../../modules/access_tokens_guard/access_token.js' + +test.group('AccessToken token | decode', () => { + test('decode "{input}" as token') + .with([ + { + input: null, + output: null, + }, + { + input: '', + output: null, + }, + { + input: '..', + output: null, + }, + { + input: 'foobar', + output: null, + }, + { + input: 'foo.baz', + output: null, + }, + { + input: 'foo_baz.foo', + output: null, + }, + { + input: `api_bar.${base64.urlEncode('baz')}`, + output: null, + }, + { + input: `api_${base64.urlEncode('baz')}.bar`, + output: null, + }, + { + input: `api_${base64.urlEncode('baz')}.${base64.urlEncode('baz')}`, + output: null, + }, + { + input: `auth_token_`, + output: null, + }, + { + input: `auth_token_..`, + output: null, + }, + { + input: `auth_token_foo.bar`, + output: null, + }, + { + input: `auth_token_${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`, + output: { + identifier: 'bar', + secret: 'baz', + }, + }, + ]) + .run(({ assert }, { input, output }) => { + const decoded = AccessToken.decode('auth_token_', input as string) + if (!decoded) { + assert.deepEqual(decoded, output) + } else { + assert.deepEqual( + { identifier: decoded.identifier, secret: decoded.secret.release() }, + output + ) + } + }) +}) + +test.group('AccessToken token | create', () => { + test('create a transient token', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = AccessToken.createTransientToken(1, 40, '20 mins') + assert.equal(token.userId, 1) + assert.exists(token.hash) + assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) + assert.instanceOf(token.secret, Secret) + }) + + test('create a long-lived transient token', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = AccessToken.createTransientToken(1, 40) + assert.equal(token.userId, 1) + assert.exists(token.hash) + assert.isUndefined(token.expiresAt) + assert.instanceOf(token.secret, Secret) + }) + + test('create token from persisted information', ({ assert }) => { + const createdAt = new Date() + const updatedAt = new Date() + const lastUsedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) + + const token = new AccessToken({ + identifier: '12', + tokenableId: 1, + type: 'auth_token', + hash: '1234', + createdAt, + updatedAt, + expiresAt, + lastUsedAt, + }) + + assert.equal(token.identifier, '12') + assert.equal(token.hash, '1234') + assert.equal(token.tokenableId, 1) + assert.equal(token.createdAt.getTime(), createdAt.getTime()) + assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) + assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) + assert.equal(token.lastUsedAt!.getTime(), lastUsedAt.getTime()) + assert.equal(token.type, 'auth_token') + assert.deepEqual(token.abilities, ['*']) + + assert.isUndefined(token.value) + assert.isFalse(token.isExpired()) + }) + + test('create token with a secret', ({ assert }) => { + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) + + const transientToken = AccessToken.createTransientToken(1, 40, '20 mins') + assert.throws( + () => + new AccessToken({ + identifier: '12', + tokenableId: 1, + type: 'auth_token', + hash: transientToken.hash, + createdAt, + updatedAt, + expiresAt, + lastUsedAt: null, + secret: transientToken.secret, + }), + 'Cannot compute token value without the prefix' + ) + + const token = new AccessToken({ + identifier: '12', + tokenableId: 1, + type: 'auth_token', + hash: transientToken.hash, + createdAt, + updatedAt, + expiresAt, + lastUsedAt: null, + prefix: 'oat_', + secret: transientToken.secret, + }) + + const decoded = AccessToken.decode('oat_', token.value!.release()) + + assert.equal(token.identifier, '12') + assert.equal(token.tokenableId, 1) + assert.equal(token.hash, transientToken.hash) + assert.instanceOf(token.value, Secret) + assert.isTrue(token.verify(transientToken.secret)) + assert.isTrue(token.verify(decoded!.secret)) + assert.equal(token.createdAt.getTime(), createdAt.getTime()) + assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) + assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) + assert.isFalse(token.isExpired()) + }) + + test('verify token hash', ({ assert }) => { + const transientToken = AccessToken.createTransientToken(1, 40, '20 mins') + + const token = new AccessToken({ + identifier: '12', + tokenableId: 1, + type: 'auth_token', + hash: transientToken.hash, + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: null, + lastUsedAt: null, + prefix: 'oat_', + secret: transientToken.secret, + }) + + assert.isTrue(token.verify(transientToken.secret)) + }) + + test('check if a token allows or denies an ability', ({ assert }) => { + const transientToken = AccessToken.createTransientToken(1, 40, '20 mins') + + const token = new AccessToken({ + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: transientToken.expiresAt || null, + lastUsedAt: null, + hash: transientToken.hash, + identifier: '12', + type: 'auth_token', + tokenableId: transientToken.userId, + abilities: ['*'], + }) + const tokenWithPermissions = new AccessToken({ + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: transientToken.expiresAt || null, + lastUsedAt: null, + hash: transientToken.hash, + identifier: '12', + type: 'auth_token', + tokenableId: transientToken.userId, + abilities: ['gist:read'], + }) + + assert.isTrue(token.allows('gist:read')) + assert.isTrue(token.allows('gist:delete')) + assert.isFalse(token.denies('gist:read')) + assert.isFalse(token.denies('gist:delete')) + + assert.isTrue(tokenWithPermissions.allows('gist:read')) + assert.isFalse(tokenWithPermissions.allows('gist:delete')) + assert.isFalse(tokenWithPermissions.denies('gist:read')) + assert.isTrue(tokenWithPermissions.denies('gist:delete')) + }) +}) diff --git a/tests/access_tokens/guard/authenticate.spec.ts b/tests/access_tokens/guard/authenticate.spec.ts index 8edbb5b..083ef0a 100644 --- a/tests/access_tokens/guard/authenticate.spec.ts +++ b/tests/access_tokens/guard/authenticate.spec.ts @@ -333,3 +333,15 @@ test.group('Access token guard | check', () => { assert.isTrue(guard.authenticationAttempted) }) }) + +test.group('Access tokens guard | authenticateAsClient', () => { + test('throw error when using authenticateAsClient method', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new AccessTokensFakeUserProvider() + + const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) + const user = await userProvider.findById(1) + await assert.rejects(() => guard.authenticateAsClient(user!.getOriginal()), 'Not supported') + }) +}) diff --git a/tests/access_tokens/token_providers/db.spec.ts b/tests/access_tokens/token_providers/db.spec.ts index d870f8f..381b614 100644 --- a/tests/access_tokens/token_providers/db.spec.ts +++ b/tests/access_tokens/token_providers/db.spec.ts @@ -206,7 +206,34 @@ test.group('Access tokens provider | DB | create', () => { const user = new User() await assert.rejects( () => User.authTokens.create(user), - 'Cannot generate access token for "User" model. The value of "id" is undefined or null' + 'Cannot use "User" model for managing access tokens. The value of column "id" is undefined or null' + ) + }) + + test('throw error when user is not an instance of the associated model', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + await assert.rejects( + // @ts-expect-error + () => User.authTokens.create({}), + 'Invalid user object. It must be an instance of the "User" model' ) }) }) diff --git a/tests/access_tokens/user_providers/lucid.spec.ts b/tests/access_tokens/user_providers/lucid.spec.ts new file mode 100644 index 0000000..a944ace --- /dev/null +++ b/tests/access_tokens/user_providers/lucid.spec.ts @@ -0,0 +1,262 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Secret } from '@adonisjs/core/helpers' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +import { createDatabase, createTables } from '../../helpers.js' +import { AccessToken } from '../../../modules/access_tokens_guard/access_token.js' +import { DbAccessTokensProvider } from '../../../modules/access_tokens_guard/token_providers/db.js' +import { AccessTokensLucidUserProvider } from '../../../modules/access_tokens_guard/user_providers/lucid.js' + +test.group('Access user provider | Lucid', () => { + test('throw error when user does not implement a token provider', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const userProvider = new AccessTokensLucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + } as any) + + await assert.rejects( + () => userProvider.verifyToken(new Secret('foo')), + 'Cannot use "User" for verifying access tokens. Make sure to assign a token provider to the model.' + ) + }) +}) + +test.group('Access user provider | Lucid | verify', () => { + test('return access token when it is valid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const userProvider = new AccessTokensLucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + const freshToken = await userProvider.verifyToken(new Secret(token.value!.release())) + assert.instanceOf(freshToken, AccessToken) + assert.isUndefined(freshToken!.value) + assert.equal(freshToken!.type, token.type) + assert.equal(freshToken!.hash, token.hash) + assert.equal(freshToken!.createdAt.getTime(), token.createdAt.getTime()) + assert.instanceOf(freshToken!.lastUsedAt, Date) + }) +}) + +test.group('Access user provider | Lucid | findById', () => { + test('find user by id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const userProvider = new AccessTokensLucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user) + const freshToken = await userProvider.verifyToken(new Secret(token.value!.release())) + const freshUser = await userProvider.findById(freshToken!.tokenableId) + + assert.instanceOf(freshUser!.getOriginal(), User) + assert.equal(freshUser!.getId(), user.id) + }) + + test('return null when user does not exist', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const userProvider = new AccessTokensLucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + const token = await User.authTokens.create(user) + await user.delete() + + const freshToken = await userProvider.verifyToken(new Secret(token.value!.release())) + const freshUser = await userProvider.findById(freshToken!.tokenableId) + assert.isNull(freshUser) + }) +}) + +test.group('Access user provider | Lucid | createUserForGuard', () => { + test('throw error via getId when user does not have an id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const userProvider = new AccessTokensLucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + + const user = await userProvider.createUserForGuard(new User()) + assert.throws( + () => user.getId(), + 'Cannot use "User" model for authentication. The value of column "id" is undefined or null' + ) + }) + + test('throw error via getId when user is not an instance of the associated model', async ({ + assert, + }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const userProvider = new AccessTokensLucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + + await assert.rejects( + // @ts-expect-error + () => userProvider.createUserForGuard({}), + 'Invalid user object. It must be an instance of the "User" model' + ) + }) +}) From 59c6d787883afdfabdec5f65a8f2e209ac7884a2 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 15 Jan 2024 14:29:32 +0530 Subject: [PATCH 74/96] feat: implement withAuthFinder mixin --- src/errors.ts | 99 +++---- src/mixins/with_auth_finder.ts | 99 +++++++ tests/auth/mixins/with_auth_finder.spec.ts | 294 +++++++++++++++++++++ 3 files changed, 424 insertions(+), 68 deletions(-) create mode 100644 src/mixins/with_auth_finder.ts create mode 100644 tests/auth/mixins/with_auth_finder.spec.ts diff --git a/src/errors.ts b/src/errors.ts index 3db8927..bf311a9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -146,58 +146,6 @@ export const E_INVALID_CREDENTIALS = class extends Exception { */ identifier: string = 'errors.E_INVALID_CREDENTIALS' - /** - * The guard name reference that raised the exception. It allows - * us to customize the logic of handling the exception. - */ - guardDriverName: string - - /** - * A collection of renderers to render the exception to a - * response. - * - * The collection is a key-value pair, where the key is - * the guard driver name and value is a factory function - * to respond to the request. - */ - renderers: Record< - string, - (message: string, error: this, ctx: HttpContext) => Promise | void - > = { - /** - * Response when session driver is used - */ - session: (message, error, ctx) => { - switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { - case 'html': - case null: - ctx.session.flashExcept(['_csrf']) - ctx.session.flashErrors({ [error.code!]: message }) - ctx.response.redirect('back', true) - break - case 'json': - ctx.response.status(error.status).send({ - errors: [ - { - message, - }, - ], - }) - break - case 'application/vnd.api+json': - ctx.response.status(error.status).send({ - errors: [ - { - code: error.code, - title: message, - }, - ], - }) - break - } - }, - } - /** * Returns the message to be sent in the HTTP response. * Feel free to override this method and return a custom @@ -210,27 +158,42 @@ export const E_INVALID_CREDENTIALS = class extends Exception { return error.message } - constructor( - message: string, - options: { - guardDriverName: string - } - ) { - super(message, {}) - this.guardDriverName = options.guardDriverName - } - /** * Converts exception to an HTTP response */ async handle(error: this, ctx: HttpContext) { - const renderer = this.renderers[this.guardDriverName] - const message = error.getResponseMessage(error, ctx) + const message = this.getResponseMessage(error, ctx) - if (!renderer) { - return ctx.response.status(error.status).send(message) + switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { + case 'html': + case null: + if (ctx.session) { + ctx.session.flashExcept(['_csrf']) + ctx.session.flashErrors({ [error.code!]: message }) + ctx.response.redirect('back', true) + } else { + ctx.response.status(error.status).send(message) + } + break + case 'json': + ctx.response.status(error.status).send({ + errors: [ + { + message, + }, + ], + }) + break + case 'application/vnd.api+json': + ctx.response.status(error.status).send({ + errors: [ + { + code: error.code, + title: message, + }, + ], + }) + break } - - return renderer(message, error, ctx) } } diff --git a/src/mixins/with_auth_finder.ts b/src/mixins/with_auth_finder.ts new file mode 100644 index 0000000..923e231 --- /dev/null +++ b/src/mixins/with_auth_finder.ts @@ -0,0 +1,99 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Hash } from '@adonisjs/core/hash' +import { RuntimeException } from '@adonisjs/core/exceptions' +import { beforeSave, type BaseModel } from '@adonisjs/lucid/orm' +import type { NormalizeConstructor } from '@adonisjs/core/types/helpers' +import { E_INVALID_CREDENTIALS } from '../errors.js' + +/** + * Mixing to add user lookup and password verification methods + * on a model. + * + * Under the hood, this mixin defines following methods and hooks + * + * - beforeSave hook to hash user password + * - findForAuth method to find a user during authentication + * - verifyCredentials method to verify user credentials and prevent + * timing attacks. + */ +export function withAuthFinder( + hash: Hash, + options: { + uids: string[] + passwordColumnName: string + } +) { + return >(superclass: Model) => { + class UserWithUserFinder extends superclass { + /** + * Hook to verify user password when creating or updating + * the user model. + */ + @beforeSave() + static async hashPassword( + this: T, + user: InstanceType + ) { + if (user.$dirty[options.passwordColumnName]) { + ;(user as any)[options.passwordColumnName] = await hash.make( + (user as any)[options.passwordColumnName] + ) + } + } + + /** + * Finds the user for authentication via "verifyCredentials". + * Feel free to override this method customize the user + * lookup behavior. + */ + static findForAuth( + this: T, + uids: string[], + value: string + ): Promise | null> { + const query = this.query() + uids.forEach((uid) => query.orWhere(uid, value)) + return query.limit(1).first() + } + + /** + * Find a user by uid and verify their password. This method is + * safe from timing attacks. + */ + static async verifyCredentials( + this: T, + uid: string, + password: string + ) { + const user = await this.findForAuth(options.uids, uid) + if (!user) { + await hash.make(password) + throw new E_INVALID_CREDENTIALS('Invalid user credentials') + } + + const passwordHash = (user as any)[options.passwordColumnName] + if (!passwordHash) { + throw new RuntimeException( + `Cannot verify password during login. The value of column "${options.passwordColumnName}" is undefined or null` + ) + } + + if (await hash.verify(passwordHash, password)) { + return user + } + + throw new E_INVALID_CREDENTIALS('Invalid user credentials') + } + } + + return UserWithUserFinder + } +} diff --git a/tests/auth/mixins/with_auth_finder.spec.ts b/tests/auth/mixins/with_auth_finder.spec.ts new file mode 100644 index 0000000..ee17f2d --- /dev/null +++ b/tests/auth/mixins/with_auth_finder.spec.ts @@ -0,0 +1,294 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import convertHrtime from 'convert-hrtime' +import { compose } from '@adonisjs/core/helpers' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +import { createDatabase, createTables, getHasher } from '../../helpers.js' +import { withAuthFinder } from '../../../src/mixins/with_auth_finder.js' + +test.group('withAuthFinder | findForAuth', () => { + test('find user for authentication using the mixin', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const hash = getHasher() + + class User extends compose( + BaseModel, + withAuthFinder(hash, { + uids: ['email', 'username'], + passwordColumnName: 'password', + }) + ) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + await User.create({ + username: 'virk', + email: 'virk@adonisjs.com', + password: 'secret', + }) + + const userByEmail = await User.findForAuth(['username', 'email'], 'virk@adonisjs.com') + const userByUsername = await User.findForAuth(['username', 'email'], 'virk') + + expectTypeOf(userByEmail).toEqualTypeOf() + expectTypeOf(userByUsername).toEqualTypeOf() + + assert.instanceOf(userByEmail, User) + assert.instanceOf(userByUsername, User) + }) + + test('return null when user does not exists', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const hash = getHasher() + + class User extends compose( + BaseModel, + withAuthFinder(hash, { + uids: ['email', 'username'], + passwordColumnName: 'password', + }) + ) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const userByEmail = await User.findForAuth(['username', 'email'], 'virk@adonisjs.com') + const userByUsername = await User.findForAuth(['username', 'email'], 'virk') + + expectTypeOf(userByEmail).toEqualTypeOf() + expectTypeOf(userByUsername).toEqualTypeOf() + + assert.isNull(userByEmail) + assert.isNull(userByUsername) + }) +}) + +test.group('withAuthFinder | verify', () => { + test('return user instance when credentials are correct', async ({ assert, expectTypeOf }) => { + const db = await createDatabase() + await createTables(db) + + const hash = getHasher() + + class User extends compose( + BaseModel, + withAuthFinder(hash, { + uids: ['email', 'username'], + passwordColumnName: 'password', + }) + ) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + await User.create({ + username: 'virk', + email: 'virk@adonisjs.com', + password: 'secret', + }) + + const user = await User.verifyCredentials('virk@adonisjs.com', 'secret') + expectTypeOf(user).toEqualTypeOf() + assert.instanceOf(user, User) + }) + + test('throw error when user does not exists', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const hash = getHasher() + + class User extends compose( + BaseModel, + withAuthFinder(hash, { + uids: ['email', 'username'], + passwordColumnName: 'password', + }) + ) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + await assert.rejects( + () => User.verifyCredentials('virk@adonisjs.com', 'secret'), + 'Invalid user credentials' + ) + }) + + test('throw error when password is incorrect', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const hash = getHasher() + + class User extends compose( + BaseModel, + withAuthFinder(hash, { + uids: ['email', 'username'], + passwordColumnName: 'password', + }) + ) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + await User.create({ + username: 'virk', + email: 'virk@adonisjs.com', + password: 'secret', + }) + + await assert.rejects( + () => User.verifyCredentials('virk@adonisjs.com', 'supersecret'), + 'Invalid user credentials' + ) + }) + + test('throw error when user does not have a password', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const hash = getHasher() + + class User extends compose( + BaseModel, + withAuthFinder(hash, { + uids: ['email', 'username'], + passwordColumnName: 'password', + }) + ) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string | null + } + + await User.create({ + username: 'virk', + email: 'virk@adonisjs.com', + password: null, + }) + + await assert.rejects( + () => User.verifyCredentials('virk@adonisjs.com', 'supersecret'), + 'Cannot verify password during login. The value of column "password" is undefined or null' + ) + }) + + test('prevent timing attacks', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + const hash = getHasher() + + class User extends compose( + BaseModel, + withAuthFinder(hash, { + uids: ['email', 'username'], + passwordColumnName: 'password', + }) + ) { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + await User.create({ + username: 'virk', + email: 'virk@adonisjs.com', + password: 'secret', + }) + + let startTime = process.hrtime.bigint() + try { + await User.verifyCredentials('baz@bar.com', 'secret') + } catch {} + const invalidEmailTime = convertHrtime(process.hrtime.bigint() - startTime) + + startTime = process.hrtime.bigint() + try { + await User.verifyCredentials('virk@adonisjs.com', 'supersecret') + } catch {} + const invalidPasswordTime = convertHrtime(process.hrtime.bigint() - startTime) + + /** + * Same timing within the range of 10 milliseconds is acceptable + */ + assert.isBelow(Math.abs(invalidPasswordTime.seconds - invalidEmailTime.seconds), 1) + assert.isBelow(Math.abs(invalidPasswordTime.milliseconds - invalidEmailTime.milliseconds), 10) + }) +}) From d8a3a77153f9d2e671c6bfff90e8fc1bb44881c3 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 16 Jan 2024 18:12:45 +0530 Subject: [PATCH 75/96] feat: write tests for japa api clients --- factories/access_tokens/main.ts | 4 +- factories/auth/main.ts | 8 +- modules/access_tokens_guard/guard.ts | 11 ++- .../access_tokens_guard/token_providers/db.ts | 2 +- modules/access_tokens_guard/types.ts | 22 ++++- .../user_providers/lucid.ts | 23 ++++- package.json | 3 + src/auth_manager.ts | 18 +--- src/plugins/japa/api_client.ts | 35 +++---- src/plugins/japa/browser_client.ts | 33 ++++--- src/types.ts | 5 +- .../access_tokens/guard/authenticate.spec.ts | 4 +- tests/auth/plugins/api_client.spec.ts | 98 +++++++++++++++++++ tests/auth/plugins/browser_client.spec.ts | 96 ++++++++++++++++++ tests/auth/plugins/global_types.ts | 26 +++++ 15 files changed, 329 insertions(+), 59 deletions(-) create mode 100644 tests/auth/plugins/api_client.spec.ts create mode 100644 tests/auth/plugins/browser_client.spec.ts create mode 100644 tests/auth/plugins/global_types.ts diff --git a/factories/access_tokens/main.ts b/factories/access_tokens/main.ts index af929fc..f4860db 100644 --- a/factories/access_tokens/main.ts +++ b/factories/access_tokens/main.ts @@ -71,8 +71,8 @@ export class AccessTokensFakeUserProvider async createToken( user: AccessTokensFakeUser, - expiresIn?: string | number, - abilities?: string[] + abilities?: string[], + expiresIn?: string | number ): Promise { const transientToken = AccessToken.createTransientToken(user.id, 40, expiresIn) const id = stringHelpers.random(15) diff --git a/factories/auth/main.ts b/factories/auth/main.ts index e4bc806..9b5074a 100644 --- a/factories/auth/main.ts +++ b/factories/auth/main.ts @@ -65,7 +65,11 @@ export class FakeGuard implements GuardContract { } } - async authenticateAsClient(_: FakeUser): Promise { - throw new Error('Not supported') + async authenticateAsClient( + _user: FakeUser, + _abilities?: string[], + _expiresIn?: string | number + ): Promise { + return {} } } diff --git a/modules/access_tokens_guard/guard.ts b/modules/access_tokens_guard/guard.ts index 50080c1..c3396d8 100644 --- a/modules/access_tokens_guard/guard.ts +++ b/modules/access_tokens_guard/guard.ts @@ -204,9 +204,16 @@ export class AccessTokensGuard { - throw new Error('Not supported') + const token = await this.#userProvider.createToken(user, abilities, expiresIn) + return { + headers: { + authorization: `Bearer ${token.value!.release()}`, + }, + } } /** diff --git a/modules/access_tokens_guard/token_providers/db.ts b/modules/access_tokens_guard/token_providers/db.ts index 942fa3f..9e1eda2 100644 --- a/modules/access_tokens_guard/token_providers/db.ts +++ b/modules/access_tokens_guard/token_providers/db.ts @@ -25,7 +25,7 @@ import { RuntimeException } from '@adonisjs/core/exceptions' * The user must be an instance of the associated user model. */ export class DbAccessTokensProvider - implements AccessTokensProviderContract + implements AccessTokensProviderContract { /** * Create tokens provider instance for a given Lucid model diff --git a/modules/access_tokens_guard/types.ts b/modules/access_tokens_guard/types.ts index 81a356f..9631b67 100644 --- a/modules/access_tokens_guard/types.ts +++ b/modules/access_tokens_guard/types.ts @@ -126,7 +126,16 @@ export type AccessTokenDbColumns = { * Access token providers are used verify an access token * during authentication */ -export interface AccessTokensProviderContract { +export interface AccessTokensProviderContract { + /** + * Create a token for a given user + */ + create( + user: InstanceType, + abilities?: string[], + expiresIn?: string | number + ): Promise + /** * Verifies a publicly shared access token and returns an * access token for it. @@ -139,7 +148,7 @@ export interface AccessTokensProviderContract { * authentication */ export type LucidTokenable = LucidModel & { - [K in TokenableProperty]: AccessTokensProviderContract + [K in TokenableProperty]: AccessTokensProviderContract } /** @@ -181,6 +190,15 @@ export interface AccessTokensUserProviderContract { */ createUserForGuard(user: RealUser): Promise> + /** + * Create a token for a given user + */ + createToken( + user: RealUser, + abilities?: string[], + expiresIn?: string | number + ): Promise + /** * Find a user by their id. */ diff --git a/modules/access_tokens_guard/user_providers/lucid.ts b/modules/access_tokens_guard/user_providers/lucid.ts index 420e38b..22b3d50 100644 --- a/modules/access_tokens_guard/user_providers/lucid.ts +++ b/modules/access_tokens_guard/user_providers/lucid.ts @@ -77,10 +77,6 @@ export class AccessTokensLucidUserProvider< user: InstanceType ): Promise>> { const model = await this.getModel() - - /** - * Ensure user is an instance of the model - */ if (user instanceof model === false) { throw new RuntimeException( `Invalid user object. It must be an instance of the "${model.name}" model` @@ -106,6 +102,25 @@ export class AccessTokensLucidUserProvider< } } + /** + * Create a token for a given user + */ + async createToken( + user: InstanceType, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined + ): Promise { + const model = await this.getModel() + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + const tokensProvider = await this.getTokensProvider() + return tokensProvider.create(user, abilities, expiresIn) + } + /** * Finds a user by their primary key value */ diff --git a/package.json b/package.json index 184ba77..a7439bd 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/luxon": "^3.4.0", "@types/node": "^20.10.8", "@types/set-cookie-parser": "^2.4.7", + "@types/sinon": "^17.0.3", "c8": "^9.0.0", "convert-hrtime": "^5.0.0", "copyfiles": "^2.4.1", @@ -91,10 +92,12 @@ "github-label-sync": "^2.3.1", "husky": "^8.0.3", "luxon": "^3.4.4", + "nock": "^13.5.0", "np": "^9.2.0", "playwright": "^1.40.1", "prettier": "^3.1.1", "set-cookie-parser": "^2.6.0", + "sinon": "^17.0.1", "sqlite3": "^5.1.7", "timekeeper": "^2.3.1", "ts-node": "^10.9.2", diff --git a/src/auth_manager.ts b/src/auth_manager.ts index 75c7d50..0ca9eb5 100644 --- a/src/auth_manager.ts +++ b/src/auth_manager.ts @@ -18,23 +18,15 @@ import { AuthenticatorClient } from './authenticator_client.js' * guards from the config */ export class AuthManager> { - /** - * Registered guards - */ - #config: { - default: keyof KnownGuards - guards: KnownGuards - } - /** * Name of the default guard */ get defaultGuard() { - return this.#config.default + return this.config.default } - constructor(config: { default: keyof KnownGuards; guards: KnownGuards }) { - this.#config = config + constructor(public config: { default: keyof KnownGuards; guards: KnownGuards }) { + this.config = config } /** @@ -42,7 +34,7 @@ export class AuthManager> { * is used to authenticated in incoming HTTP request */ createAuthenticator(ctx: HttpContext) { - return new Authenticator(ctx, this.#config) + return new Authenticator(ctx, this.config) } /** @@ -50,6 +42,6 @@ export class AuthManager> { * used to setup authentication state during testing. */ createAuthenticatorClient() { - return new AuthenticatorClient(this.#config) + return new AuthenticatorClient(this.config) } } diff --git a/src/plugins/japa/api_client.ts b/src/plugins/japa/api_client.ts index ae01537..b4b9bfe 100644 --- a/src/plugins/japa/api_client.ts +++ b/src/plugins/japa/api_client.ts @@ -19,21 +19,24 @@ import type { Authenticators, GuardContract, GuardFactory } from '../../types.js declare module '@japa/api-client' { export interface ApiRequest { authData: { - guard: string - user: unknown + guard: keyof Authenticators | '__default__' + args: [unknown, ...any[]] } /** * Login a user using the default authentication guard * when making an API call */ - loginAs(user: { - [K in keyof Authenticators]: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + loginAs( + user: { + [K in keyof Authenticators]: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never : never - : never - }): this + }[keyof Authenticators], + ...args: any[] + ): this /** * Define the authentication guard for login @@ -46,10 +49,8 @@ declare module '@japa/api-client' { * Login a user using a specific auth guard */ loginAs( - user: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A - : never + ...args: ReturnType extends GuardContract + ? Parameters['authenticateAsClient']> : never ): Self } @@ -68,10 +69,10 @@ export const authApiClient = (app: ApplicationService) => { * Login a user using the default authentication guard * when making an API call */ - ApiRequest.macro('loginAs', function (this: ApiRequest, user) { + ApiRequest.macro('loginAs', function (this: ApiRequest, user, ...args: any[]) { this.authData = { guard: '__default__', - user: user, + args: [user, ...args], } return this }) @@ -84,10 +85,10 @@ export const authApiClient = (app: ApplicationService) => { Self extends ApiRequest, >(this: Self, guard: K) { return { - loginAs: (user) => { + loginAs: (...args) => { this.authData = { guard, - user: user, + args: args, } return this }, @@ -107,7 +108,7 @@ export const authApiClient = (app: ApplicationService) => { const client = auth.createAuthenticatorClient() const guard = authData.guard === '__default__' ? client.use() : client.use(authData.guard) const requestData = await (guard as GuardContract).authenticateAsClient( - authData.user + ...authData.args ) if (requestData.headers) { diff --git a/src/plugins/japa/browser_client.ts b/src/plugins/japa/browser_client.ts index c5a071c..d58d4ea 100644 --- a/src/plugins/japa/browser_client.ts +++ b/src/plugins/japa/browser_client.ts @@ -24,13 +24,16 @@ declare module 'playwright' { * Login a user using the default authentication guard when * using the browser context to make page visits */ - loginAs(user: { - [K in keyof Authenticators]: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + loginAs( + user: { + [K in keyof Authenticators]: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never : never - : never - }): Promise + }[keyof Authenticators], + ...args: any[] + ): Promise /** * Define the authentication guard for login @@ -42,9 +45,13 @@ declare module 'playwright' { * Login a user using a specific auth guard */ loginAs( - user: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + user: ReturnType extends GuardContract ? A : never, + ...args: ReturnType extends GuardContract + ? ReturnType['authenticateAsClient'] extends ( + _: A, + ...args: infer Args + ) => any + ? Args : never : never ): Promise @@ -70,10 +77,10 @@ export const authBrowserClient = (app: ApplicationService) => { */ context.withGuard = function (guardName) { return { - async loginAs(user) { + async loginAs(...args) { const client = auth.createAuthenticatorClient() const guard = client.use(guardName) as GuardContract - const requestData = await guard.authenticateAsClient(user) + const requestData = await guard.authenticateAsClient(...args) if (requestData.headers) { throw new RuntimeException( @@ -100,10 +107,10 @@ export const authBrowserClient = (app: ApplicationService) => { * Login a user using the default authentication guard when * using the browser context to make page visits */ - context.loginAs = async function (user) { + context.loginAs = async function (user, ...args) { const client = auth.createAuthenticatorClient() const guard = client.use() as GuardContract - const requestData = await guard.authenticateAsClient(user) + const requestData = await guard.authenticateAsClient(user, ...args) if (requestData.headers) { throw new RuntimeException(`Cannot use "${guard.driverName}" guard with browser client`) diff --git a/src/types.ts b/src/types.ts index ce0a1e5..07f2e86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,8 +71,11 @@ export interface GuardContract { * The method is used to authenticate the user as client. * This method should return cookies, headers, or * session state. + * + * The rest of the arguments can be anything the guard wants + * to accept */ - authenticateAsClient(user: User): Promise + authenticateAsClient(user: User, ...args: any[]): Promise /** * Aymbol for infer the events emitted by a specific diff --git a/tests/access_tokens/guard/authenticate.spec.ts b/tests/access_tokens/guard/authenticate.spec.ts index 083ef0a..b291a56 100644 --- a/tests/access_tokens/guard/authenticate.spec.ts +++ b/tests/access_tokens/guard/authenticate.spec.ts @@ -243,7 +243,7 @@ test.group('Access tokens guard | authenticate', () => { const emitter = createEmitter() const userProvider = new AccessTokensFakeUserProvider() const user = await userProvider.findById(1) - const token = await userProvider.createToken(user!.getOriginal(), '20 mins') + const token = await userProvider.createToken(user!.getOriginal(), ['*'], '20 mins') timeTravel(21 * 60) ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` @@ -278,7 +278,7 @@ test.group('Access tokens guard | authenticate', () => { const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) const user = await userProvider.findById(1) - const token = await userProvider.createToken(user!.getOriginal(), '20 mins') + const token = await userProvider.createToken(user!.getOriginal(), ['*'], '20 mins') await assert.rejects(() => guard.authenticate(), 'Unauthorized access') ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` diff --git a/tests/auth/plugins/api_client.spec.ts b/tests/auth/plugins/api_client.spec.ts new file mode 100644 index 0000000..05c2614 --- /dev/null +++ b/tests/auth/plugins/api_client.spec.ts @@ -0,0 +1,98 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import nock from 'nock' +import sinon from 'sinon' +import { test } from '@japa/runner' +import { apiClient } from '@japa/api-client' +import { runner } from '@japa/runner/factories' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService } from '@adonisjs/core/types' + +import { Guards } from './global_types.js' +import { AuthManager } from '../../../src/auth_manager.js' +import { FakeGuard, FakeUser } from '../../../factories/auth/main.js' +import { authApiClient } from '../../../src/plugins/japa/api_client.js' + +test.group('Api client | loginAs', () => { + test('login user using the guard authenticate as client method', async ({ + assert, + expectTypeOf, + }) => { + const fakeGuard = new FakeGuard() + const guards: Guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [apiClient({ baseURL: 'http://localhost:3333' }), authApiClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ client }) => { + const request = client.get('/') + await request.loginAs({ id: 1 }) + expectTypeOf(request.loginAs).parameters.toEqualTypeOf<[FakeUser, ...any[]]>() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 })) + }) + + test('pass additional params to loginAs method', async ({ assert, expectTypeOf }) => { + const fakeGuard = new FakeGuard() + const guards: Guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [apiClient({ baseURL: 'http://localhost:3333' }), authApiClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ client }) => { + const request = client.get('/') + await request.withGuard('web').loginAs({ id: 1 }, ['*'], '20 mins') + expectTypeOf(request.withGuard('web').loginAs).parameters.toEqualTypeOf< + [ + user: FakeUser, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined, + ] + >() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 }, ['*'], '20 mins')) + }) +}) diff --git a/tests/auth/plugins/browser_client.spec.ts b/tests/auth/plugins/browser_client.spec.ts new file mode 100644 index 0000000..4b0ae8c --- /dev/null +++ b/tests/auth/plugins/browser_client.spec.ts @@ -0,0 +1,96 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import nock from 'nock' +import sinon from 'sinon' +import { test } from '@japa/runner' +import { runner } from '@japa/runner/factories' +import { browserClient } from '@japa/browser-client' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService } from '@adonisjs/core/types' + +import { Guards } from './global_types.js' +import { AuthManager } from '../../../src/auth_manager.js' +import { FakeGuard, FakeUser } from '../../../factories/auth/main.js' +import { authBrowserClient } from '../../../src/plugins/japa/browser_client.js' + +test.group('Api client | loginAs', () => { + test('login user using the guard authenticate as client method', async ({ + assert, + expectTypeOf, + }) => { + const fakeGuard = new FakeGuard() + const guards: Guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [browserClient({}), authBrowserClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ browserContext }) => { + await browserContext.loginAs({ id: 1 }) + expectTypeOf(browserContext.loginAs).parameters.toEqualTypeOf<[FakeUser, ...any[]]>() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 })) + }) + + test('pass additional params to loginAs method', async ({ assert, expectTypeOf }) => { + const fakeGuard = new FakeGuard() + const guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [browserClient({}), authBrowserClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ browserContext }) => { + await browserContext.withGuard('web').loginAs({ id: 1 }, ['*'], '20 mins') + expectTypeOf(browserContext.withGuard('web').loginAs).parameters.toEqualTypeOf< + [ + user: FakeUser, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined, + ] + >() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 }, ['*'], '20 mins')) + }) +}) diff --git a/tests/auth/plugins/global_types.ts b/tests/auth/plugins/global_types.ts new file mode 100644 index 0000000..8d8edb9 --- /dev/null +++ b/tests/auth/plugins/global_types.ts @@ -0,0 +1,26 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { FakeGuard } from '../../../factories/auth/main.js' + +/** + * Guard to use for testing + */ +export type Guards = { + web: () => FakeGuard +} + +/** + * Inferrring types for the authenticators, since + * the japa plugins relies on the singleton + * service + */ +declare module '@adonisjs/auth/types' { + interface Authenticators extends Guards {} +} From 7bbe9a727d85022a8a6a3b9df308196664dafb83 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 16 Jan 2024 18:21:35 +0530 Subject: [PATCH 76/96] test: fix breaking tests --- src/plugins/japa/api_client.ts | 1 + src/plugins/japa/browser_client.ts | 2 ++ tests/access_tokens/guard/authenticate.spec.ts | 7 +++++-- tests/auth/authenticator_client.spec.ts | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/plugins/japa/api_client.ts b/src/plugins/japa/api_client.ts index b4b9bfe..9e6fae8 100644 --- a/src/plugins/japa/api_client.ts +++ b/src/plugins/japa/api_client.ts @@ -111,6 +111,7 @@ export const authApiClient = (app: ApplicationService) => { ...authData.args ) + /* c8 ignore next 13 */ if (requestData.headers) { debug('defining headers with api client request %O', requestData.headers) request.headers(requestData.headers) diff --git a/src/plugins/japa/browser_client.ts b/src/plugins/japa/browser_client.ts index d58d4ea..ffa0e65 100644 --- a/src/plugins/japa/browser_client.ts +++ b/src/plugins/japa/browser_client.ts @@ -82,6 +82,7 @@ export const authBrowserClient = (app: ApplicationService) => { const guard = client.use(guardName) as GuardContract const requestData = await guard.authenticateAsClient(...args) + /* c8 ignore next 17 */ if (requestData.headers) { throw new RuntimeException( `Cannot use "${guard.driverName}" guard with browser client` @@ -112,6 +113,7 @@ export const authBrowserClient = (app: ApplicationService) => { const guard = client.use() as GuardContract const requestData = await guard.authenticateAsClient(user, ...args) + /* c8 ignore next 15 */ if (requestData.headers) { throw new RuntimeException(`Cannot use "${guard.driverName}" guard with browser client`) } diff --git a/tests/access_tokens/guard/authenticate.spec.ts b/tests/access_tokens/guard/authenticate.spec.ts index b291a56..631ad02 100644 --- a/tests/access_tokens/guard/authenticate.spec.ts +++ b/tests/access_tokens/guard/authenticate.spec.ts @@ -335,13 +335,16 @@ test.group('Access token guard | check', () => { }) test.group('Access tokens guard | authenticateAsClient', () => { - test('throw error when using authenticateAsClient method', async ({ assert }) => { + test('create bearer token for the given user', async ({ assert }) => { const ctx = new HttpContextFactory().create() const emitter = createEmitter>() const userProvider = new AccessTokensFakeUserProvider() const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) const user = await userProvider.findById(1) - await assert.rejects(() => guard.authenticateAsClient(user!.getOriginal()), 'Not supported') + const response = await guard.authenticateAsClient(user!.getOriginal()) + + assert.property(response.headers, 'authorization') + assert.match(response.headers!.authorization, /Bearer oat_[a-zA-Z0-9]+\.[a-zA-Z0-9]+/) }) }) diff --git a/tests/auth/authenticator_client.spec.ts b/tests/auth/authenticator_client.spec.ts index 8dd9791..bacf61e 100644 --- a/tests/auth/authenticator_client.spec.ts +++ b/tests/auth/authenticator_client.spec.ts @@ -49,6 +49,6 @@ test.group('Authenticator client', () => { }, }) - await assert.rejects(() => client.use('web').authenticateAsClient({ id: 1 }), 'Not supported') + await assert.doesNotReject(() => client.use('web').authenticateAsClient({ id: 1 })) }) }) From 7c92c3fe9a78b32484910f9119f6ced3895bc0e5 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 16 Jan 2024 18:54:16 +0530 Subject: [PATCH 77/96] feat: add config helpers for access tokens guard --- index.ts | 1 + modules/access_tokens_guard/define_config.ts | 48 ++++++ modules/access_tokens_guard/main.ts | 24 +++ .../user_providers/lucid.ts | 10 +- package.json | 4 +- tests/access_tokens/define_config.spec.ts | 137 ++++++++++++++++++ 6 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 modules/access_tokens_guard/define_config.ts create mode 100644 modules/access_tokens_guard/main.ts create mode 100644 tests/access_tokens/define_config.spec.ts diff --git a/index.ts b/index.ts index f70df41..2628c35 100644 --- a/index.ts +++ b/index.ts @@ -13,4 +13,5 @@ export * as symbols from './src/symbols.js' export { AuthManager } from './src/auth_manager.js' export { defineConfig } from './src/define_config.js' export { Authenticator } from './src/authenticator.js' +export { withAuthFinder } from './src/mixins/with_auth_finder.js' export { AuthenticatorClient } from './src/authenticator_client.js' diff --git a/modules/access_tokens_guard/define_config.ts b/modules/access_tokens_guard/define_config.ts new file mode 100644 index 0000000..dd0823e --- /dev/null +++ b/modules/access_tokens_guard/define_config.ts @@ -0,0 +1,48 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import type { ConfigProvider } from '@adonisjs/core/types' + +import { AccessTokensGuard } from './guard.js' +import type { GuardConfigProvider } from '../../src/types.js' +import { AccessTokensLucidUserProvider } from './user_providers/lucid.js' +import type { + LucidTokenable, + AccessTokensUserProviderContract, + AccessTokensLucidUserProviderOptions, +} from './types.js' + +/** + * Configures access tokens guard for authentication + */ +export function accessTokensGuard>( + userProvider: UserProvider | ConfigProvider +): GuardConfigProvider<(ctx: HttpContext) => AccessTokensGuard> { + return { + async resolver(name, app) { + const emitter = await app.container.make('emitter') + const provider = 'resolver' in userProvider ? await userProvider.resolver(app) : userProvider + return (ctx) => new AccessTokensGuard(name, ctx, emitter as any, provider) + }, + } +} + +/** + * Configures user provider that uses Lucid models to verify + * access tokens and find users during authentication. + */ +export function accessTokensLucidProvider< + TokenableProperty extends string, + Model extends LucidTokenable, +>( + config: AccessTokensLucidUserProviderOptions +): AccessTokensLucidUserProvider { + return new AccessTokensLucidUserProvider(config) +} diff --git a/modules/access_tokens_guard/main.ts b/modules/access_tokens_guard/main.ts new file mode 100644 index 0000000..b449089 --- /dev/null +++ b/modules/access_tokens_guard/main.ts @@ -0,0 +1,24 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { accessTokensGuard, accessTokensLucidProvider } from './define_config.js' + +export { AccessToken } from './access_token.js' +export { AccessTokensGuard } from './guard.js' +export { DbAccessTokensProvider } from './token_providers/db.js' +export { AccessTokensLucidUserProvider } from './user_providers/lucid.js' + +/** + * Exposes configuration helpers to configure the access tokens + * guard and the lucid user provider + */ +export const accessTokens = { + guard: accessTokensGuard, + lucidUserProvider: accessTokensLucidProvider, +} diff --git a/modules/access_tokens_guard/user_providers/lucid.ts b/modules/access_tokens_guard/user_providers/lucid.ts index 22b3d50..9930949 100644 --- a/modules/access_tokens_guard/user_providers/lucid.ts +++ b/modules/access_tokens_guard/user_providers/lucid.ts @@ -7,15 +7,15 @@ * file that was distributed with this source code. */ +import { Secret } from '@adonisjs/core/helpers' import { RuntimeException } from '@adonisjs/core/exceptions' import { PROVIDER_REAL_USER } from '../../../src/symbols.js' -import { +import type { AccessTokensGuardUser, AccessTokensLucidUserProviderOptions, AccessTokensUserProviderContract, LucidTokenable, } from '../types.js' -import { Secret } from '@adonisjs/core/helpers' import { AccessToken } from '../access_token.js' /** @@ -23,8 +23,8 @@ import { AccessToken } from '../access_token.js' * authentication */ export class AccessTokensLucidUserProvider< - TokensProperty extends string, - UserModel extends LucidTokenable, + TokenableProperty extends string, + UserModel extends LucidTokenable, > implements AccessTokensUserProviderContract> { declare [PROVIDER_REAL_USER]: InstanceType @@ -38,7 +38,7 @@ export class AccessTokensLucidUserProvider< /** * Lucid provider options */ - protected options: AccessTokensLucidUserProviderOptions + protected options: AccessTokensLucidUserProviderOptions ) {} /** diff --git a/package.json b/package.json index a7439bd..7577c96 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "./plugins/api_client": "./build/src/plugins/japa/api_client.js", "./plugins/browser_client": "./build/src/plugins/japa/browser_client.js", "./services/main": "./build/services/auth.js", - "./initialize_auth_middleware": "./build/src/middleware/initialize_auth_middleware.js" + "./initialize_auth_middleware": "./build/src/middleware/initialize_auth_middleware.js", + "./access_tokens": "./build/modules/access_tokens/main.js", + "./types/access_tokens": "./build/modules/access_tokens/types.js" }, "scripts": { "pretest": "npm run lint", diff --git a/tests/access_tokens/define_config.spec.ts b/tests/access_tokens/define_config.spec.ts new file mode 100644 index 0000000..29cb3d4 --- /dev/null +++ b/tests/access_tokens/define_config.spec.ts @@ -0,0 +1,137 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService } from '@adonisjs/core/types' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { createEmitter } from '../helpers.js' +import { + accessTokens, + AccessTokensGuard, + DbAccessTokensProvider, + AccessTokensLucidUserProvider, +} from '../../modules/access_tokens_guard/main.js' +import { configProvider } from '@adonisjs/core' + +test.group('defineConfig', () => { + test('configure lucid user provider', ({ assert, expectTypeOf }) => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const userProvider = accessTokens.lucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + assert.instanceOf(userProvider, AccessTokensLucidUserProvider) + expectTypeOf(userProvider).toEqualTypeOf< + AccessTokensLucidUserProvider<'authTokens', typeof User> + >() + }) + + test('configure access tokens guard', async ({ assert, expectTypeOf }) => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const ctx = new HttpContextFactory().create() + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + app.container.bind('emitter', () => createEmitter()) + + const guard = await accessTokens + .guard( + accessTokens.lucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + ) + .resolver('api', app) + + assert.instanceOf(guard(ctx), AccessTokensGuard) + expectTypeOf(guard).returns.toEqualTypeOf< + AccessTokensGuard> + >() + }) + + test('register user provider from a config provider', async ({ assert, expectTypeOf }) => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const ctx = new HttpContextFactory().create() + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + app.container.bind('emitter', () => createEmitter()) + + const userProvider = configProvider.create(async () => { + return accessTokens.lucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + }) + const guard = await accessTokens.guard(userProvider).resolver('api', app) + + assert.instanceOf(guard(ctx), AccessTokensGuard) + expectTypeOf(guard).returns.toEqualTypeOf< + AccessTokensGuard> + >() + }) +}) From 9f1a0fada6161db9f54dfe4f2b26b98ade70d7c0 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 10:17:28 +0530 Subject: [PATCH 78/96] feat: add current access token property on authenticated user --- modules/access_tokens_guard/guard.ts | 33 ++++++++++++++----- .../access_tokens/guard/authenticate.spec.ts | 11 ++++++- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/modules/access_tokens_guard/guard.ts b/modules/access_tokens_guard/guard.ts index c3396d8..90d7772 100644 --- a/modules/access_tokens_guard/guard.ts +++ b/modules/access_tokens_guard/guard.ts @@ -11,6 +11,7 @@ import { Secret } from '@adonisjs/core/helpers' import type { HttpContext } from '@adonisjs/core/http' import type { EmitterLike } from '@adonisjs/core/types/events' +import type { AccessToken } from './access_token.js' import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' import type { AuthClientResponse, GuardContract } from '../../src/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' @@ -22,12 +23,15 @@ import type { AccessTokensGuardEvents, AccessTokensUserProviderContract } from ' * used to seamlessly integrate with the auth layer of the package. */ export class AccessTokensGuard> - implements GuardContract + implements + GuardContract { /** * Events emitted by the guard */ - declare [GUARD_KNOWN_EVENTS]: AccessTokensGuardEvents + declare [GUARD_KNOWN_EVENTS]: AccessTokensGuardEvents< + UserProvider[typeof PROVIDER_REAL_USER] & { currentAccessToken: AccessToken } + > /** * A unique name for the guard. @@ -47,7 +51,11 @@ export class AccessTokensGuard> + #emitter: EmitterLike< + AccessTokensGuardEvents< + UserProvider[typeof PROVIDER_REAL_USER] & { currentAccessToken: AccessToken } + > + > /** * Driver name of the guard @@ -77,12 +85,16 @@ export class AccessTokensGuard>, + emitter: EmitterLike< + AccessTokensGuardEvents< + UserProvider[typeof PROVIDER_REAL_USER] & { currentAccessToken: AccessToken } + > + >, userProvider: UserProvider ) { this.#name = name @@ -126,7 +138,7 @@ export class AccessTokensGuard { + async authenticate(): Promise< + UserProvider[typeof PROVIDER_REAL_USER] & { currentAccessToken: AccessToken } + > { /** * Return early when authentication has already * been attempted @@ -184,7 +198,10 @@ export class AccessTokensGuard { - test('return user when access token is valid', async ({ assert }) => { + test('return user when access token is valid', async ({ assert, expectTypeOf }) => { const ctx = new HttpContextFactory().create() const emitter = createEmitter>() const userProvider = new AccessTokensFakeUserProvider() @@ -36,9 +37,17 @@ test.group('Access tokens guard | authenticate', () => { guard.authenticate(), ]) + expectTypeOf(authenticatedUser).toEqualTypeOf< + AccessTokensFakeUser & { currentAccessToken: AccessToken } + >() + expectTypeOf(guard.user).toEqualTypeOf< + (AccessTokensFakeUser & { currentAccessToken: AccessToken }) | undefined + >() assert.equal(attempted!.guardName, 'api') assert.equal(succeeded!.guardName, 'api') assert.equal(succeeded!.token.identifier, token.identifier) + assert.property(guard.user, 'currentAccessToken') + assert.instanceOf(guard.user!.currentAccessToken, AccessToken) assert.deepEqual(guard.user, authenticatedUser) assert.deepEqual(guard.getUserOrFail(), authenticatedUser) From 724e643c23c15276cd49e62de6f3b1cdb65aa5a5 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 10:24:44 +0530 Subject: [PATCH 79/96] fix: middleware to use internal imports and containerResolver --- src/middleware/initialize_auth_middleware.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/middleware/initialize_auth_middleware.ts b/src/middleware/initialize_auth_middleware.ts index 0f0d2f1..19cd5cc 100644 --- a/src/middleware/initialize_auth_middleware.ts +++ b/src/middleware/initialize_auth_middleware.ts @@ -1,9 +1,11 @@ /// -import auth from '@adonisjs/auth/services/main' import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' +import type { Authenticator } from '../authenticator.js' +import type { Authenticators, GuardFactory } from '../types.js' + /** * The "InitializeAuthMiddleware" is used to create a request * specific authenticator instance for every HTTP request. @@ -13,6 +15,8 @@ import type { NextFn } from '@adonisjs/core/types/http' */ export default class InitializeAuthMiddleware { async handle(ctx: HttpContext, next: NextFn) { + const auth = await ctx.containerResolver.make('auth.manager') + /** * Initialize the authenticator for the current HTTP * request @@ -32,6 +36,8 @@ export default class InitializeAuthMiddleware { declare module '@adonisjs/core/http' { export interface HttpContext { - auth: ReturnType<(typeof auth)['createAuthenticator']> + auth: Authenticator< + Authenticators extends Record ? Authenticators : never + > } } From 24c7744add803bc526013579a698f2e86633f8de Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 10:25:21 +0530 Subject: [PATCH 80/96] refactor: make japa plugins infer params from authenticateAsClient method --- package.json | 4 ++-- src/authenticator.ts | 13 +++++++++---- src/plugins/japa/api_client.ts | 9 ++++----- src/plugins/japa/browser_client.ts | 23 +++++++++-------------- tests/auth/plugins/api_client.spec.ts | 8 +++++++- tests/auth/plugins/browser_client.spec.ts | 8 +++++++- 6 files changed, 38 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 7577c96..08331c1 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "./plugins/browser_client": "./build/src/plugins/japa/browser_client.js", "./services/main": "./build/services/auth.js", "./initialize_auth_middleware": "./build/src/middleware/initialize_auth_middleware.js", - "./access_tokens": "./build/modules/access_tokens/main.js", - "./types/access_tokens": "./build/modules/access_tokens/types.js" + "./access_tokens": "./build/modules/access_tokens_guard/main.js", + "./types/access_tokens": "./build/modules/access_tokens_guard/types.js" }, "scripts": { "pretest": "npm run lint", diff --git a/src/authenticator.ts b/src/authenticator.ts index eb6a259..0af536f 100644 --- a/src/authenticator.ts +++ b/src/authenticator.ts @@ -171,8 +171,9 @@ export class Authenticator> { * method multiple times triggers multiple authentication with the * guard. */ - authenticate() { - return this.authenticateUsing() + async authenticate() { + await this.authenticateUsing() + return this.getUserOrFail() } /** @@ -202,7 +203,11 @@ export class Authenticator> { async authenticateUsing( guards?: (keyof KnownGuards)[], options?: { loginRoute?: string } - ): Promise { + ): Promise< + { + [K in keyof KnownGuards]: ReturnType['getUserOrFail']> + }[keyof KnownGuards] + > { const guardsToUse = guards || [this.defaultGuard] let lastUsedDriver: string | undefined @@ -215,7 +220,7 @@ export class Authenticator> { if (await guard.check()) { this.#authenticatedViaGuard = guardName - return true + return this.getUserOrFail() } } diff --git a/src/plugins/japa/api_client.ts b/src/plugins/japa/api_client.ts index 9e6fae8..7fc817e 100644 --- a/src/plugins/japa/api_client.ts +++ b/src/plugins/japa/api_client.ts @@ -28,14 +28,13 @@ declare module '@japa/api-client' { * when making an API call */ loginAs( - user: { + ...args: { [K in keyof Authenticators]: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + ? ReturnType extends GuardContract + ? Parameters['authenticateAsClient']> : never : never - }[keyof Authenticators], - ...args: any[] + }[keyof Authenticators] ): this /** diff --git a/src/plugins/japa/browser_client.ts b/src/plugins/japa/browser_client.ts index ffa0e65..c7e4eff 100644 --- a/src/plugins/japa/browser_client.ts +++ b/src/plugins/japa/browser_client.ts @@ -25,14 +25,13 @@ declare module 'playwright' { * using the browser context to make page visits */ loginAs( - user: { + ...args: { [K in keyof Authenticators]: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + ? ReturnType extends GuardContract + ? Parameters['authenticateAsClient']> : never : never - }[keyof Authenticators], - ...args: any[] + }[keyof Authenticators] ): Promise /** @@ -45,14 +44,8 @@ declare module 'playwright' { * Login a user using a specific auth guard */ loginAs( - user: ReturnType extends GuardContract ? A : never, - ...args: ReturnType extends GuardContract - ? ReturnType['authenticateAsClient'] extends ( - _: A, - ...args: infer Args - ) => any - ? Args - : never + ...args: ReturnType extends GuardContract + ? Parameters['authenticateAsClient']> : never ): Promise } @@ -80,7 +73,9 @@ export const authBrowserClient = (app: ApplicationService) => { async loginAs(...args) { const client = auth.createAuthenticatorClient() const guard = client.use(guardName) as GuardContract - const requestData = await guard.authenticateAsClient(...args) + const requestData = await guard.authenticateAsClient( + ...(args as [user: unknown, ...any[]]) + ) /* c8 ignore next 17 */ if (requestData.headers) { diff --git a/tests/auth/plugins/api_client.spec.ts b/tests/auth/plugins/api_client.spec.ts index 05c2614..995cf86 100644 --- a/tests/auth/plugins/api_client.spec.ts +++ b/tests/auth/plugins/api_client.spec.ts @@ -51,7 +51,13 @@ test.group('Api client | loginAs', () => { .runTest('sample test', async ({ client }) => { const request = client.get('/') await request.loginAs({ id: 1 }) - expectTypeOf(request.loginAs).parameters.toEqualTypeOf<[FakeUser, ...any[]]>() + expectTypeOf(request.loginAs).parameters.toEqualTypeOf< + [ + user: FakeUser, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined, + ] + >() }) assert.isTrue(spy.calledOnceWithExactly({ id: 1 })) diff --git a/tests/auth/plugins/browser_client.spec.ts b/tests/auth/plugins/browser_client.spec.ts index 4b0ae8c..06563cd 100644 --- a/tests/auth/plugins/browser_client.spec.ts +++ b/tests/auth/plugins/browser_client.spec.ts @@ -50,7 +50,13 @@ test.group('Api client | loginAs', () => { }) .runTest('sample test', async ({ browserContext }) => { await browserContext.loginAs({ id: 1 }) - expectTypeOf(browserContext.loginAs).parameters.toEqualTypeOf<[FakeUser, ...any[]]>() + expectTypeOf(browserContext.loginAs).parameters.toEqualTypeOf< + [ + user: FakeUser, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined, + ] + >() }) assert.isTrue(spy.calledOnceWithExactly({ id: 1 })) From 2520439511563792395abc475731ebfe756dca9a Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 10:34:32 +0530 Subject: [PATCH 81/96] feat: add access_tokens driver error responder --- src/errors.ts | 30 +++++++++++++++++++ .../access_tokens/guard/authenticate.spec.ts | 7 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index bf311a9..9c90347 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -93,6 +93,36 @@ export const E_UNAUTHORIZED_ACCESS = class extends Exception { .header('WWW-Authenticate', `Basic realm="Authenticate", charset="UTF-8"`) .send(message) }, + + /** + * Response when access tokens driver is used + */ + access_tokens: (message, error, ctx) => { + switch (ctx.request.accepts(['application/vnd.api+json', 'json'])) { + case null: + ctx.response.status(error.status).send(message) + break + case 'json': + ctx.response.status(error.status).send({ + errors: [ + { + message, + }, + ], + }) + break + case 'application/vnd.api+json': + ctx.response.status(error.status).send({ + errors: [ + { + code: error.code, + title: message, + }, + ], + }) + break + } + }, } /** diff --git a/tests/access_tokens/guard/authenticate.spec.ts b/tests/access_tokens/guard/authenticate.spec.ts index e5cfc89..b0b6caa 100644 --- a/tests/access_tokens/guard/authenticate.spec.ts +++ b/tests/access_tokens/guard/authenticate.spec.ts @@ -318,7 +318,12 @@ test.group('Access token guard | check', () => { const isLoggedIn = await guard.check() assert.isTrue(isLoggedIn) - assert.deepEqual(guard.user, { id: 1, email: 'virk@adonisjs.com', password: 'secret' }) + assert.deepEqual(guard.user, { + id: 1, + email: 'virk@adonisjs.com', + password: 'secret', + currentAccessToken: await userProvider.verifyToken(token.value!), + }) assert.isTrue(guard.isAuthenticated) assert.isTrue(guard.authenticationAttempted) }) From 2e43db9450519b3ebd74d33362fde21c6eeb3aae Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 10:34:43 +0530 Subject: [PATCH 82/96] test: fix types issue --- tests/auth/e_invalid_credentials.spec.ts | 32 ++++-------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/tests/auth/e_invalid_credentials.spec.ts b/tests/auth/e_invalid_credentials.spec.ts index baabad6..6be2770 100644 --- a/tests/auth/e_invalid_credentials.spec.ts +++ b/tests/auth/e_invalid_credentials.spec.ts @@ -14,12 +14,10 @@ import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { E_INVALID_CREDENTIALS } from '../../src/errors.js' -test.group('Errors | E_INVALID_CREDENTIALS | session', () => { +test.group('Errors | E_INVALID_CREDENTIALS', () => { test('report error via flash messages and redirect', async ({ assert }) => { const sessionMiddleware = await new SessionMiddlewareFactory().create() - const error = new E_INVALID_CREDENTIALS('Invalid credentials', { - guardDriverName: 'session', - }) + const error = new E_INVALID_CREDENTIALS('Invalid credentials') const ctx = new HttpContextFactory().create() await sessionMiddleware.handle(ctx, async () => { @@ -34,9 +32,7 @@ test.group('Errors | E_INVALID_CREDENTIALS | session', () => { }) test('respond with json', async ({ assert }) => { - const error = new E_INVALID_CREDENTIALS('Invalid credentials', { - guardDriverName: 'session', - }) + const error = new E_INVALID_CREDENTIALS('Invalid credentials') const ctx = new HttpContextFactory().create() @@ -57,9 +53,7 @@ test.group('Errors | E_INVALID_CREDENTIALS | session', () => { }) test('respond with JSONAPI response', async ({ assert }) => { - const error = new E_INVALID_CREDENTIALS('Invalid credentials', { - guardDriverName: 'session', - }) + const error = new E_INVALID_CREDENTIALS('Invalid credentials') const ctx = new HttpContextFactory().create() @@ -81,9 +75,7 @@ test.group('Errors | E_INVALID_CREDENTIALS | session', () => { }) test('translate error message using i18n', async ({ assert }) => { - const error = new E_INVALID_CREDENTIALS('Invalid credentials', { - guardDriverName: 'session', - }) + const error = new E_INVALID_CREDENTIALS('Invalid credentials') const i18nManager = new I18nManagerFactory() .merge({ config: { @@ -124,17 +116,3 @@ test.group('Errors | E_INVALID_CREDENTIALS | session', () => { }) }) }) - -test.group('Errors | E_INVALID_CREDENTIALS | unknown guard', () => { - test('send plain text response', async ({ assert }) => { - const error = new E_INVALID_CREDENTIALS('Invalid credentials', { - guardDriverName: 'foo', - }) - - const ctx = new HttpContextFactory().create() - await error.handle(error, ctx) - - assert.equal(ctx.response.getStatus(), 400) - assert.equal(ctx.response.getBody(), 'Invalid credentials') - }) -}) From 9d085b51d96e971895e3ede2db9d414756f25acf Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 10:37:25 +0530 Subject: [PATCH 83/96] feat: add authorize method to access token --- modules/access_tokens_guard/access_token.ts | 10 ++++++++++ tests/access_tokens/access_token.spec.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/modules/access_tokens_guard/access_token.ts b/modules/access_tokens_guard/access_token.ts index f96608f..6995405 100644 --- a/modules/access_tokens_guard/access_token.ts +++ b/modules/access_tokens_guard/access_token.ts @@ -13,6 +13,7 @@ import { RuntimeException } from '@adonisjs/core/exceptions' import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' import { CRC32 } from './crc32.js' +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' /** * Access token represents a token created for a user to authenticate @@ -208,6 +209,15 @@ export class AccessToken { return !this.abilities.includes(ability) && !this.abilities.includes('*') } + /** + * Authorize ability access using the current access token + */ + authorize(ability: string) { + if (this.denies(ability)) { + throw new E_UNAUTHORIZED_ACCESS('Unauthorized access', { guardDriverName: 'access_tokens' }) + } + } + /** * Check if the token has been expired. Verifies * the "expiresAt" timestamp with the current diff --git a/tests/access_tokens/access_token.spec.ts b/tests/access_tokens/access_token.spec.ts index 5989a69..16af69a 100644 --- a/tests/access_tokens/access_token.spec.ts +++ b/tests/access_tokens/access_token.spec.ts @@ -243,10 +243,14 @@ test.group('AccessToken token | create', () => { assert.isTrue(token.allows('gist:delete')) assert.isFalse(token.denies('gist:read')) assert.isFalse(token.denies('gist:delete')) + assert.doesNotThrow(() => token.authorize('gist:read')) + assert.doesNotThrow(() => token.authorize('gist:delete')) assert.isTrue(tokenWithPermissions.allows('gist:read')) assert.isFalse(tokenWithPermissions.allows('gist:delete')) assert.isFalse(tokenWithPermissions.denies('gist:read')) assert.isTrue(tokenWithPermissions.denies('gist:delete')) + assert.doesNotThrow(() => tokenWithPermissions.authorize('gist:read')) + assert.throws(() => tokenWithPermissions.authorize('gist:delete'), 'Unauthorized access') }) }) From 1eaf3d91c838e7a7799247d1919c688217ac2344 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 10:45:07 +0530 Subject: [PATCH 84/96] feat: add serialization to access token --- modules/access_tokens_guard/access_token.ts | 8 ++++++ tests/access_tokens/access_token.spec.ts | 28 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/modules/access_tokens_guard/access_token.ts b/modules/access_tokens_guard/access_token.ts index 6995405..8cb97a1 100644 --- a/modules/access_tokens_guard/access_token.ts +++ b/modules/access_tokens_guard/access_token.ts @@ -240,4 +240,12 @@ export class AccessToken { const newHash = createHash('sha256').update(secret.release()).digest('hex') return safeEqual(this.hash, newHash) } + + toJSON() { + return { + type: 'bearer', + token: this.value ? this.value.release() : undefined, + expiresAt: this.expiresAt, + } + } } diff --git a/tests/access_tokens/access_token.spec.ts b/tests/access_tokens/access_token.spec.ts index 16af69a..f348e56 100644 --- a/tests/access_tokens/access_token.spec.ts +++ b/tests/access_tokens/access_token.spec.ts @@ -253,4 +253,32 @@ test.group('AccessToken token | create', () => { assert.doesNotThrow(() => tokenWithPermissions.authorize('gist:read')) assert.throws(() => tokenWithPermissions.authorize('gist:delete'), 'Unauthorized access') }) + + test('convert token to JSON', ({ assert }) => { + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) + + const transientToken = AccessToken.createTransientToken(1, 40, '20 mins') + + const token = new AccessToken({ + identifier: '12', + tokenableId: 1, + type: 'auth_token', + hash: transientToken.hash, + createdAt, + updatedAt, + expiresAt, + lastUsedAt: null, + prefix: 'oat_', + secret: transientToken.secret, + }) + + assert.deepEqual(token.toJSON(), { + type: 'bearer', + token: token.value!.release(), + expiresAt: token.expiresAt, + }) + }) }) From 7dc935acf0cb121021b732afe0c111f6c814c79e Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 17:32:16 +0530 Subject: [PATCH 85/96] feat: add name property on access token --- modules/access_tokens_guard/access_token.ts | 10 ++++++++++ tests/access_tokens/access_token.spec.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/modules/access_tokens_guard/access_token.ts b/modules/access_tokens_guard/access_token.ts index 8cb97a1..5ad96d3 100644 --- a/modules/access_tokens_guard/access_token.ts +++ b/modules/access_tokens_guard/access_token.ts @@ -119,6 +119,11 @@ export class AccessToken { */ value?: Secret + /** + * Recognizable name for the token + */ + name?: string + /** * A unique type to identify a bucket of tokens inside the * storage layer. @@ -166,12 +171,14 @@ export class AccessToken { updatedAt: Date lastUsedAt: Date | null expiresAt: Date | null + name?: string prefix?: string secret?: Secret abilities?: string[] }) { this.identifier = attributes.identifier this.tokenableId = attributes.tokenableId + this.name = attributes.name this.hash = attributes.hash this.type = attributes.type this.createdAt = attributes.createdAt @@ -244,7 +251,10 @@ export class AccessToken { toJSON() { return { type: 'bearer', + name: this.name, token: this.value ? this.value.release() : undefined, + abilities: this.abilities, + lastUsedAt: this.lastUsedAt, expiresAt: this.expiresAt, } } diff --git a/tests/access_tokens/access_token.spec.ts b/tests/access_tokens/access_token.spec.ts index f348e56..c20c3b6 100644 --- a/tests/access_tokens/access_token.spec.ts +++ b/tests/access_tokens/access_token.spec.ts @@ -121,6 +121,7 @@ test.group('AccessToken token | create', () => { const token = new AccessToken({ identifier: '12', + name: 'foo', tokenableId: 1, type: 'auth_token', hash: '1234', @@ -133,6 +134,7 @@ test.group('AccessToken token | create', () => { assert.equal(token.identifier, '12') assert.equal(token.hash, '1234') assert.equal(token.tokenableId, 1) + assert.equal(token.name, 'foo') assert.equal(token.createdAt.getTime(), createdAt.getTime()) assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) @@ -277,7 +279,10 @@ test.group('AccessToken token | create', () => { assert.deepEqual(token.toJSON(), { type: 'bearer', + name: undefined, token: token.value!.release(), + abilities: ['*'], + lastUsedAt: null, expiresAt: token.expiresAt, }) }) From 85f018efd11b95cc45c77a310c6521eb736e3623 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 17:43:41 +0530 Subject: [PATCH 86/96] feat: assign name to access tokens --- modules/access_tokens_guard/access_token.ts | 4 +- .../access_tokens_guard/token_providers/db.ts | 10 ++- modules/access_tokens_guard/types.ts | 15 ++++- .../user_providers/lucid.ts | 7 +- .../access_tokens/token_providers/db.spec.ts | 66 +++++++++++++++++-- tests/auth/plugins/browser_client.spec.ts | 2 +- tests/helpers.ts | 1 + 7 files changed, 90 insertions(+), 15 deletions(-) diff --git a/modules/access_tokens_guard/access_token.ts b/modules/access_tokens_guard/access_token.ts index 5ad96d3..625590e 100644 --- a/modules/access_tokens_guard/access_token.ts +++ b/modules/access_tokens_guard/access_token.ts @@ -122,7 +122,7 @@ export class AccessToken { /** * Recognizable name for the token */ - name?: string + name: string /** * A unique type to identify a bucket of tokens inside the @@ -171,7 +171,7 @@ export class AccessToken { updatedAt: Date lastUsedAt: Date | null expiresAt: Date | null - name?: string + name: string | null prefix?: string secret?: Secret abilities?: string[] diff --git a/modules/access_tokens_guard/token_providers/db.ts b/modules/access_tokens_guard/token_providers/db.ts index 9e1eda2..3f47982 100644 --- a/modules/access_tokens_guard/token_providers/db.ts +++ b/modules/access_tokens_guard/token_providers/db.ts @@ -97,6 +97,7 @@ export class DbAccessTokensProvider identifier: dbRow.id, tokenableId: dbRow.tokenable_id, type: dbRow.type, + name: dbRow.name, hash: dbRow.hash, abilities: JSON.parse(dbRow.abilities), createdAt: @@ -124,7 +125,10 @@ export class DbAccessTokensProvider async create( user: InstanceType, abilities: string[] = ['*'], - expiresIn?: string | number + options?: { + name?: string + expiresIn?: string | number + } ) { this.#ensureIsPersisted(user) @@ -138,7 +142,7 @@ export class DbAccessTokensProvider const transientToken = AccessToken.createTransientToken( user.$primaryKeyValue!, this.tokenSecretLength, - expiresIn || this.options.expiresIn + options?.expiresIn || this.options.expiresIn ) /** @@ -148,6 +152,7 @@ export class DbAccessTokensProvider const dbRow: Omit = { tokenable_id: transientToken.userId, type: this.type, + name: options?.name || null, hash: transientToken.hash, abilities: JSON.stringify(abilities), created_at: new Date(), @@ -170,6 +175,7 @@ export class DbAccessTokensProvider type: dbRow.type, prefix: this.prefix, secret: transientToken.secret, + name: dbRow.name, hash: dbRow.hash, abilities: JSON.parse(dbRow.abilities), createdAt: dbRow.created_at, diff --git a/modules/access_tokens_guard/types.ts b/modules/access_tokens_guard/types.ts index 9631b67..a3a87b1 100644 --- a/modules/access_tokens_guard/types.ts +++ b/modules/access_tokens_guard/types.ts @@ -91,6 +91,11 @@ export type AccessTokenDbColumns = { */ type: string + /** + * Optional name for the token + */ + name: string | null + /** * Token hash is used to verify the token shared * with the user @@ -133,7 +138,10 @@ export interface AccessTokensProviderContract { create( user: InstanceType, abilities?: string[], - expiresIn?: string | number + options?: { + name?: string + expiresIn?: string | number + } ): Promise /** @@ -196,7 +204,10 @@ export interface AccessTokensUserProviderContract { createToken( user: RealUser, abilities?: string[], - expiresIn?: string | number + options?: { + name?: string + expiresIn?: string | number + } ): Promise /** diff --git a/modules/access_tokens_guard/user_providers/lucid.ts b/modules/access_tokens_guard/user_providers/lucid.ts index 9930949..f6402e9 100644 --- a/modules/access_tokens_guard/user_providers/lucid.ts +++ b/modules/access_tokens_guard/user_providers/lucid.ts @@ -108,7 +108,10 @@ export class AccessTokensLucidUserProvider< async createToken( user: InstanceType, abilities?: string[] | undefined, - expiresIn?: string | number | undefined + options?: { + name?: string + expiresIn?: string | number + } ): Promise { const model = await this.getModel() if (user instanceof model === false) { @@ -118,7 +121,7 @@ export class AccessTokensLucidUserProvider< } const tokensProvider = await this.getTokensProvider() - return tokensProvider.create(user, abilities, expiresIn) + return tokensProvider.create(user, abilities, options) } /** diff --git a/tests/access_tokens/token_providers/db.spec.ts b/tests/access_tokens/token_providers/db.spec.ts index 381b614..94e77a0 100644 --- a/tests/access_tokens/token_providers/db.spec.ts +++ b/tests/access_tokens/token_providers/db.spec.ts @@ -82,7 +82,7 @@ test.group('Access tokens provider | DB | create', () => { password: 'secret', }) - const token = await User.authTokens.create(user, ['*'], '20 mins') + const token = await User.authTokens.create(user, ['*'], { expiresIn: '20 mins' }) assert.exists(token.identifier) assert.instanceOf(token, AccessToken) assert.equal(token.tokenableId, user.id) @@ -100,6 +100,54 @@ test.group('Access tokens provider | DB | create', () => { assert.isTrue(token.isExpired()) }) + test('define token name', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.authTokens.create(user, ['*'], { + expiresIn: '20 mins', + name: 'List projects', + }) + assert.exists(token.identifier) + assert.instanceOf(token, AccessToken) + assert.equal(token.tokenableId, user.id) + assert.deepEqual(token.abilities, ['*']) + assert.isNull(token.lastUsedAt) + assert.instanceOf(token.expiresAt, Date) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.equal(token.name, 'List projects') + assert.equal(token.type, 'auth_token') + assert.isTrue(token.value!.release().startsWith('oat_')) + + assert.isFalse(token.isExpired()) + timeTravel(21 * 60) + assert.isTrue(token.isExpired()) + }) + test('customize token type', async ({ assert }) => { const db = await createDatabase() await createTables(db) @@ -265,13 +313,14 @@ test.group('Access tokens provider | DB | verify', () => { password: 'secret', }) - const token = await User.authTokens.create(user) + const token = await User.authTokens.create(user, ['*'], { name: 'List projects' }) const freshToken = await User.authTokens.verify(new Secret(token.value!.release())) assert.instanceOf(freshToken, AccessToken) assert.isUndefined(freshToken!.value) assert.equal(freshToken!.type, token.type) assert.equal(freshToken!.hash, token.hash) + assert.equal(freshToken!.name, 'List projects') assert.equal(freshToken!.createdAt.getTime(), token.createdAt.getTime()) assert.instanceOf(freshToken!.lastUsedAt, Date) }) @@ -302,7 +351,7 @@ test.group('Access tokens provider | DB | verify', () => { password: 'secret', }) - const token = await User.authTokens.create(user, ['*'], '20 mins') + const token = await User.authTokens.create(user, ['*'], { expiresIn: '20 mins' }) timeTravel(21 * 60) const freshToken = await User.authTokens.verify(new Secret(token.value!.release())) @@ -505,12 +554,16 @@ test.group('Access tokens provider | DB | find', () => { password: 'secret', }) - const token = await User.authTokens.create(user, ['*'], '20 mins') + const token = await User.authTokens.create(user, ['*'], { + expiresIn: '20 mins', + name: 'List projects', + }) timeTravel(21 * 60) const freshToken = await User.authTokens.find(user, token.identifier) assert.exists(freshToken!.identifier) assert.instanceOf(freshToken, AccessToken) + assert.equal(freshToken!.name, 'List projects') assert.equal(freshToken!.tokenableId, user.id) assert.deepEqual(freshToken!.abilities, ['*']) assert.isNull(freshToken!.lastUsedAt) @@ -581,7 +634,7 @@ test.group('Access tokens provider | DB | all', () => { password: 'secret', }) - await User.authTokens.create(user, ['*'], '20 mins') + await User.authTokens.create(user, ['*'], { expiresIn: '20 mins', name: 'List projects' }) await User.authTokens.create(user) timeTravel(21 * 60) const tokens = await User.authTokens.all(user) @@ -610,6 +663,7 @@ test.group('Access tokens provider | DB | all', () => { assert.instanceOf(tokens[1].createdAt, Date) assert.instanceOf(tokens[1].updatedAt, Date) assert.isDefined(tokens[1].hash) + assert.equal(tokens[1].name, 'List projects') assert.equal(tokens[1].type, 'auth_token') assert.isUndefined(tokens[1].value) assert.isTrue(tokens[1].isExpired()) @@ -641,7 +695,7 @@ test.group('Access tokens provider | DB | all', () => { password: 'secret', }) - const token = await User.authTokens.create(user, ['*'], '20 mins') + const token = await User.authTokens.create(user, ['*'], { expiresIn: '20 mins' }) await User.authTokens.create(user) /** diff --git a/tests/auth/plugins/browser_client.spec.ts b/tests/auth/plugins/browser_client.spec.ts index 06563cd..c53d5a7 100644 --- a/tests/auth/plugins/browser_client.spec.ts +++ b/tests/auth/plugins/browser_client.spec.ts @@ -20,7 +20,7 @@ import { AuthManager } from '../../../src/auth_manager.js' import { FakeGuard, FakeUser } from '../../../factories/auth/main.js' import { authBrowserClient } from '../../../src/plugins/japa/browser_client.js' -test.group('Api client | loginAs', () => { +test.group('Browser client | loginAs', () => { test('login user using the guard authenticate as client method', async ({ assert, expectTypeOf, diff --git a/tests/helpers.ts b/tests/helpers.ts index 5fa3554..133d140 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -87,6 +87,7 @@ export async function createTables(db: Database) { table.increments() table.integer('tokenable_id').notNullable().unsigned() table.integer('type').notNullable() + table.string('name').nullable() table.string('hash', 80).notNullable() table.json('abilities').notNullable() table.timestamp('created_at').notNullable() From b283cca19e242db24708200c1b4e8d3c714a869f Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 17 Jan 2024 17:45:42 +0530 Subject: [PATCH 87/96] refactor: rename defineConfig helpers to match docs --- modules/access_tokens_guard/define_config.ts | 4 ++-- modules/access_tokens_guard/guard.ts | 7 +++++-- modules/access_tokens_guard/main.ts | 12 +----------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/modules/access_tokens_guard/define_config.ts b/modules/access_tokens_guard/define_config.ts index dd0823e..f6ac6b1 100644 --- a/modules/access_tokens_guard/define_config.ts +++ b/modules/access_tokens_guard/define_config.ts @@ -22,7 +22,7 @@ import type { /** * Configures access tokens guard for authentication */ -export function accessTokensGuard>( +export function tokensGuard>( userProvider: UserProvider | ConfigProvider ): GuardConfigProvider<(ctx: HttpContext) => AccessTokensGuard> { return { @@ -38,7 +38,7 @@ export function accessTokensGuard, >( diff --git a/modules/access_tokens_guard/guard.ts b/modules/access_tokens_guard/guard.ts index 90d7772..d063519 100644 --- a/modules/access_tokens_guard/guard.ts +++ b/modules/access_tokens_guard/guard.ts @@ -223,9 +223,12 @@ export class AccessTokensGuard { - const token = await this.#userProvider.createToken(user, abilities, expiresIn) + const token = await this.#userProvider.createToken(user, abilities, options) return { headers: { authorization: `Bearer ${token.value!.release()}`, diff --git a/modules/access_tokens_guard/main.ts b/modules/access_tokens_guard/main.ts index b449089..99ef900 100644 --- a/modules/access_tokens_guard/main.ts +++ b/modules/access_tokens_guard/main.ts @@ -7,18 +7,8 @@ * file that was distributed with this source code. */ -import { accessTokensGuard, accessTokensLucidProvider } from './define_config.js' - export { AccessToken } from './access_token.js' export { AccessTokensGuard } from './guard.js' export { DbAccessTokensProvider } from './token_providers/db.js' +export { tokensGuard, tokensUserProvider } from './define_config.js' export { AccessTokensLucidUserProvider } from './user_providers/lucid.js' - -/** - * Exposes configuration helpers to configure the access tokens - * guard and the lucid user provider - */ -export const accessTokens = { - guard: accessTokensGuard, - lucidUserProvider: accessTokensLucidProvider, -} From 9491260737a6a79ffa26d5e27e2325e0f6a6baeb Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 10:22:56 +0530 Subject: [PATCH 88/96] refactor: rename define config helpers --- modules/access_tokens_guard/define_config.ts | 11 ++++--- tests/access_tokens/define_config.spec.ts | 31 ++++++++++---------- tests/auth/plugins/browser_client.spec.ts | 4 ++- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/modules/access_tokens_guard/define_config.ts b/modules/access_tokens_guard/define_config.ts index f6ac6b1..56c95a1 100644 --- a/modules/access_tokens_guard/define_config.ts +++ b/modules/access_tokens_guard/define_config.ts @@ -22,13 +22,16 @@ import type { /** * Configures access tokens guard for authentication */ -export function tokensGuard>( - userProvider: UserProvider | ConfigProvider -): GuardConfigProvider<(ctx: HttpContext) => AccessTokensGuard> { +export function tokensGuard< + UserProvider extends AccessTokensUserProviderContract, +>(config: { + provider: UserProvider | ConfigProvider +}): GuardConfigProvider<(ctx: HttpContext) => AccessTokensGuard> { return { async resolver(name, app) { const emitter = await app.container.make('emitter') - const provider = 'resolver' in userProvider ? await userProvider.resolver(app) : userProvider + const provider = + 'resolver' in config.provider ? await config.provider.resolver(app) : config.provider return (ctx) => new AccessTokensGuard(name, ctx, emitter as any, provider) }, } diff --git a/tests/access_tokens/define_config.spec.ts b/tests/access_tokens/define_config.spec.ts index 29cb3d4..7d5486d 100644 --- a/tests/access_tokens/define_config.spec.ts +++ b/tests/access_tokens/define_config.spec.ts @@ -15,8 +15,9 @@ import { HttpContextFactory } from '@adonisjs/core/factories/http' import { createEmitter } from '../helpers.js' import { - accessTokens, + tokensGuard, AccessTokensGuard, + tokensUserProvider, DbAccessTokensProvider, AccessTokensLucidUserProvider, } from '../../modules/access_tokens_guard/main.js' @@ -40,7 +41,7 @@ test.group('defineConfig', () => { static authTokens = DbAccessTokensProvider.forModel(User) } - const userProvider = accessTokens.lucidUserProvider({ + const userProvider = tokensUserProvider({ tokens: 'authTokens', async model() { return { @@ -76,18 +77,16 @@ test.group('defineConfig', () => { await app.init() app.container.bind('emitter', () => createEmitter()) - const guard = await accessTokens - .guard( - accessTokens.lucidUserProvider({ - tokens: 'authTokens', - async model() { - return { - default: User, - } - }, - }) - ) - .resolver('api', app) + const guard = await tokensGuard({ + provider: tokensUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }), + }).resolver('api', app) assert.instanceOf(guard(ctx), AccessTokensGuard) expectTypeOf(guard).returns.toEqualTypeOf< @@ -118,7 +117,7 @@ test.group('defineConfig', () => { app.container.bind('emitter', () => createEmitter()) const userProvider = configProvider.create(async () => { - return accessTokens.lucidUserProvider({ + return tokensUserProvider({ tokens: 'authTokens', async model() { return { @@ -127,7 +126,7 @@ test.group('defineConfig', () => { }, }) }) - const guard = await accessTokens.guard(userProvider).resolver('api', app) + const guard = await tokensGuard({ provider: userProvider }).resolver('api', app) assert.instanceOf(guard(ctx), AccessTokensGuard) expectTypeOf(guard).returns.toEqualTypeOf< diff --git a/tests/auth/plugins/browser_client.spec.ts b/tests/auth/plugins/browser_client.spec.ts index c53d5a7..c159b1c 100644 --- a/tests/auth/plugins/browser_client.spec.ts +++ b/tests/auth/plugins/browser_client.spec.ts @@ -20,7 +20,9 @@ import { AuthManager } from '../../../src/auth_manager.js' import { FakeGuard, FakeUser } from '../../../factories/auth/main.js' import { authBrowserClient } from '../../../src/plugins/japa/browser_client.js' -test.group('Browser client | loginAs', () => { +test.group('Browser client | loginAs', (group) => { + group.each.timeout(0) + test('login user using the guard authenticate as client method', async ({ assert, expectTypeOf, From 6e3d2cb5690b4ffbb7f4512bea24e9871bf5f6e5 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 11:14:53 +0530 Subject: [PATCH 89/96] refactor: fix typing errors --- factories/access_tokens/main.ts | 11 +++++++++-- modules/access_tokens_guard/access_token.ts | 2 +- tests/access_tokens/access_token.spec.ts | 8 +++++++- tests/access_tokens/guard/authenticate.spec.ts | 8 ++++++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/factories/access_tokens/main.ts b/factories/access_tokens/main.ts index f4860db..f7a6c33 100644 --- a/factories/access_tokens/main.ts +++ b/factories/access_tokens/main.ts @@ -58,6 +58,7 @@ export class AccessTokensFakeUserProvider tokenableId: number type: string abilities: string + name: string | null hash: string createdAt: Date updatedAt: Date @@ -72,9 +73,12 @@ export class AccessTokensFakeUserProvider async createToken( user: AccessTokensFakeUser, abilities?: string[], - expiresIn?: string | number + options?: { + name?: string + expiresIn?: string | number + } ): Promise { - const transientToken = AccessToken.createTransientToken(user.id, 40, expiresIn) + const transientToken = AccessToken.createTransientToken(user.id, 40, options?.expiresIn) const id = stringHelpers.random(15) const createdAt = new Date() const updatedAt = new Date() @@ -83,6 +87,7 @@ export class AccessTokensFakeUserProvider id, createdAt, updatedAt, + name: options?.name || null, hash: transientToken.hash, lastUsedAt: null, tokenableId: user.id, @@ -98,6 +103,7 @@ export class AccessTokensFakeUserProvider secret: transientToken.secret, prefix: 'oat_', type: 'auth_tokens', + name: options?.name || null, hash: transientToken.hash, createdAt: createdAt, updatedAt: updatedAt, @@ -142,6 +148,7 @@ export class AccessTokensFakeUserProvider abilities: JSON.parse(token.abilities), tokenableId: token.tokenableId, type: token.type, + name: token.name, hash: token.hash, createdAt: token.createdAt, updatedAt: token.updatedAt, diff --git a/modules/access_tokens_guard/access_token.ts b/modules/access_tokens_guard/access_token.ts index 625590e..da2912f 100644 --- a/modules/access_tokens_guard/access_token.ts +++ b/modules/access_tokens_guard/access_token.ts @@ -122,7 +122,7 @@ export class AccessToken { /** * Recognizable name for the token */ - name: string + name: string | null /** * A unique type to identify a bucket of tokens inside the diff --git a/tests/access_tokens/access_token.spec.ts b/tests/access_tokens/access_token.spec.ts index c20c3b6..eafc82b 100644 --- a/tests/access_tokens/access_token.spec.ts +++ b/tests/access_tokens/access_token.spec.ts @@ -159,6 +159,7 @@ test.group('AccessToken token | create', () => { identifier: '12', tokenableId: 1, type: 'auth_token', + name: null, hash: transientToken.hash, createdAt, updatedAt, @@ -173,6 +174,7 @@ test.group('AccessToken token | create', () => { identifier: '12', tokenableId: 1, type: 'auth_token', + name: null, hash: transientToken.hash, createdAt, updatedAt, @@ -203,6 +205,7 @@ test.group('AccessToken token | create', () => { identifier: '12', tokenableId: 1, type: 'auth_token', + name: null, hash: transientToken.hash, createdAt: new Date(), updatedAt: new Date(), @@ -223,6 +226,7 @@ test.group('AccessToken token | create', () => { updatedAt: new Date(), expiresAt: transientToken.expiresAt || null, lastUsedAt: null, + name: null, hash: transientToken.hash, identifier: '12', type: 'auth_token', @@ -234,6 +238,7 @@ test.group('AccessToken token | create', () => { updatedAt: new Date(), expiresAt: transientToken.expiresAt || null, lastUsedAt: null, + name: null, hash: transientToken.hash, identifier: '12', type: 'auth_token', @@ -268,6 +273,7 @@ test.group('AccessToken token | create', () => { identifier: '12', tokenableId: 1, type: 'auth_token', + name: 'my token', hash: transientToken.hash, createdAt, updatedAt, @@ -279,7 +285,7 @@ test.group('AccessToken token | create', () => { assert.deepEqual(token.toJSON(), { type: 'bearer', - name: undefined, + name: 'my token', token: token.value!.release(), abilities: ['*'], lastUsedAt: null, diff --git a/tests/access_tokens/guard/authenticate.spec.ts b/tests/access_tokens/guard/authenticate.spec.ts index b0b6caa..62d5ad6 100644 --- a/tests/access_tokens/guard/authenticate.spec.ts +++ b/tests/access_tokens/guard/authenticate.spec.ts @@ -252,7 +252,9 @@ test.group('Access tokens guard | authenticate', () => { const emitter = createEmitter() const userProvider = new AccessTokensFakeUserProvider() const user = await userProvider.findById(1) - const token = await userProvider.createToken(user!.getOriginal(), ['*'], '20 mins') + const token = await userProvider.createToken(user!.getOriginal(), ['*'], { + expiresIn: '20 mins', + }) timeTravel(21 * 60) ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` @@ -287,7 +289,9 @@ test.group('Access tokens guard | authenticate', () => { const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) const user = await userProvider.findById(1) - const token = await userProvider.createToken(user!.getOriginal(), ['*'], '20 mins') + const token = await userProvider.createToken(user!.getOriginal(), ['*'], { + expiresIn: '20 mins', + }) await assert.rejects(() => guard.authenticate(), 'Unauthorized access') ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` From 3bfc1be776a9174981b859d4bb7e2b502bfc3268 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 12:29:26 +0530 Subject: [PATCH 90/96] feat: implement remember me tokens provider --- modules/session_guard/guard.ts | 358 ++++++++++++++ modules/session_guard/remember_me_token.ts | 174 +++++++ modules/session_guard/token_providers/db.ts | 275 +++++++++++ modules/session_guard/types.ts | 241 +++++++++ tests/helpers.ts | 2 +- tests/session/tokens_providers/db.spec.ts | 514 ++++++++++++++++++++ 6 files changed, 1563 insertions(+), 1 deletion(-) create mode 100644 modules/session_guard/guard.ts create mode 100644 modules/session_guard/remember_me_token.ts create mode 100644 modules/session_guard/token_providers/db.ts create mode 100644 modules/session_guard/types.ts create mode 100644 tests/session/tokens_providers/db.spec.ts diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts new file mode 100644 index 0000000..f856d7e --- /dev/null +++ b/modules/session_guard/guard.ts @@ -0,0 +1,358 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Secret } from '@adonisjs/core/helpers' +import type { HttpContext } from '@adonisjs/core/http' +import { RuntimeException } from '@adonisjs/core/exceptions' +import type { EmitterLike } from '@adonisjs/core/types/events' + +import { RememberMeToken } from './remember_me_token.js' +import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' +import type { AuthClientResponse, GuardContract } from '../../src/types.js' +import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' +import type { SessionGuardEvents, SessionUserProviderContract } from './types.js' + +/** + * Session guard uses AdonisJS session store to track logged-in + * user information. + */ +export class SessionGuard> + implements GuardContract +{ + /** + * Events emitted by the guard + */ + declare [GUARD_KNOWN_EVENTS]: SessionGuardEvents + + /** + * A unique name for the guard. + */ + #name: string + + /** + * Reference to the current HTTP context + */ + #ctx: HttpContext + + /** + * Provider to lookup user details + */ + #userProvider: UserProvider + + /** + * Emitter to emit events + */ + #emitter: EmitterLike> + + /** + * Driver name of the guard + */ + driverName: 'session' = 'session' + + /** + * Whether or not the authentication has been attempted + * during the current request. + */ + authenticationAttempted = false + + /** + * A boolean to know if the current request has + * been authenticated + */ + isAuthenticated = false + + /** + * A boolean to know if the current request is authenticated + * using the "rememember_me" token. + */ + viaRemember = false + + /** + * A boolean to know if a remember me token was used in attempt + * to login a user. + */ + attemptedViaRemember = false + + /** + * Find if the user has been logged out during + * the current request + */ + isLoggedOut = false + + /** + * Reference to an instance of the authenticated user. + * The value only exists after calling one of the + * following methods. + * + * - authenticate + * - check + * + * You can use the "getUserOrFail" method to throw an exception if + * the request is not authenticated. + */ + user?: UserProvider[typeof PROVIDER_REAL_USER] + + /** + * The key used to store the logged-in user id inside + * session + */ + get sessionKeyName() { + return `auth_${this.#name}` + } + + /** + * The key used to store the remember me token cookie + */ + get rememberMeKeyName() { + return `remember_${this.#name}` + } + + constructor( + name: string, + ctx: HttpContext, + emitter: EmitterLike>, + userProvider: UserProvider + ) { + this.#name = name + this.#ctx = ctx + this.#emitter = emitter + this.#userProvider = userProvider + } + + /** + * Returns the session instance for the given request, + * ensuring the property exists + */ + #getSession() { + if (!('session' in this.#ctx)) { + throw new RuntimeException( + 'Cannot authenticate user. Install and configure "@adonisjs/session" package' + ) + } + + return this.#ctx.session + } + + /** + * Emits authentication failure and returns an exception + * to end the authentication cycle. + */ + #authenticationFailed(sessionId: string) { + const error = new E_UNAUTHORIZED_ACCESS('Invalid or expired user session', { + guardDriverName: this.driverName, + }) + + this.#emitter.emit('session_auth:authentication_failed', { + ctx: this.#ctx, + guardName: this.#name, + error, + sessionId, + }) + + return error + } + + /** + * Emits the authentication succeeded event and updates + * the local state to reflect successful authentication + */ + #authenticationSucceeded( + sessionId: string, + user: UserProvider[typeof PROVIDER_REAL_USER], + rememberMeToken?: RememberMeToken + ) { + this.user = user + this.isAuthenticated = true + this.isLoggedOut = false + this.viaRemember = !!rememberMeToken + + this.#emitter.emit('session_auth:authentication_succeeded', { + ctx: this.#ctx, + guardName: this.#name, + sessionId, + user, + rememberMeToken, + }) + } + + /** + * Authenticates the user using its id read from the session + * store. + * + * - We check the user exists in the db + * - If not, throw exception. + * - Otherwise, update local state to mark the user as logged-in + */ + async #authenticateViaId(userId: string | number | BigInt, sessionId: string) { + /** + * Check the user exists with the provider + */ + const providerUser = await this.#userProvider.findById(userId) + if (!providerUser) { + throw this.#authenticationFailed(sessionId) + } + + this.#authenticationSucceeded(sessionId, providerUser.getOriginal()) + return this.user + } + + /** + * Creates session for a given user by their user id. + */ + #createSessionForUser(userId: string | number | BigInt) { + const session = this.#getSession() + session.put(this.sessionKeyName, userId) + session.regenerate() + } + + /** + * Creates the remember me cookie + */ + #createRememberMeCookie(value: string) { + this.#ctx.response.encryptedCookie(this.rememberMeKeyName, value, { + // maxAge: this.#config.rememberMeTokenAge, + httpOnly: true, + }) + } + + /** + * Authenticates user from the remember me cookie. Creates a fresh + * session for them and recycles the remember me token as well. + */ + async #authenticateViaRememberCookie(rememberMeCookie: string, sessionId: string) { + /** + * Verify the token using the user provider. + */ + const token = await this.#userProvider.verifyRememberToken(new Secret(rememberMeCookie)) + if (!token) { + throw this.#authenticationFailed(sessionId) + } + + /** + * Check if a user for the token exists. Otherwise abort + * authentication + */ + const providerUser = await this.#userProvider.findById(token.tokenableId) + if (!providerUser) { + throw this.#authenticationFailed(sessionId) + } + + /** + * Create session + */ + const userId = providerUser.getId() + this.#createSessionForUser(userId) + + /** + * Emit event and update local state + */ + this.#authenticationSucceeded(sessionId, providerUser.getOriginal(), token) + + const recycledToken = await this.#userProvider.recycleRememberToken( + this.user!, + token.identifier + ) + this.#createRememberMeCookie(recycledToken.value!.release()) + return this.user + } + + /** + * Returns an instance of the authenticated user. Or throws + * an exception if the request is not authenticated. + */ + getUserOrFail(): UserProvider[typeof PROVIDER_REAL_USER] { + if (!this.user) { + throw new E_UNAUTHORIZED_ACCESS('Invalid or expired user session', { + guardDriverName: this.driverName, + }) + } + + return this.user + } + + /** + * Authenticate the current HTTP request by verifying the bearer + * token or fails with an exception + */ + async authenticate(): Promise { + /** + * Return early when authentication has already + * been attempted + */ + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + + /** + * Notify we begin to attempt the authentication + */ + this.authenticationAttempted = true + const session = this.#getSession() + + this.#emitter.emit('session_auth:authentication_attempted', { + ctx: this.#ctx, + sessionId: session.sessionId, + guardName: this.#name, + }) + + /** + * Check if there is a user id inside the session store. + * If yes, fetch the user from the persistent storage + * and mark them as logged-in + */ + const authUserId = session.get(this.sessionKeyName) + if (authUserId) { + return this.#authenticateViaId(authUserId, session.sessionId) + } + + /** + * If rememberMeCookie exists then attempt to authenticate via the + * remember me cookie + */ + const rememberMeCookie = this.#ctx.request.encryptedCookie(this.rememberMeKeyName) + if (rememberMeCookie) { + this.attemptedViaRemember = true + return this.#authenticateViaRememberCookie(rememberMeCookie, session.sessionId) + } + } + + /** + * Silently check if the user is authenticated or not, without + * throwing any exceptions + */ + async check(): Promise { + try { + await this.authenticate() + return true + } catch (error) { + if (error instanceof E_UNAUTHORIZED_ACCESS) { + return false + } + + throw error + } + } + + /** + * Returns the session info for the clients to send during + * an HTTP request to mark the user as logged-in. + */ + async authenticateAsClient( + user: UserProvider[typeof PROVIDER_REAL_USER] + ): Promise { + const providerUser = await this.#userProvider.createUserForGuard(user) + const userId = providerUser.getId() + + return { + session: { + [this.sessionKeyName]: userId, + }, + } + } +} diff --git a/modules/session_guard/remember_me_token.ts b/modules/session_guard/remember_me_token.ts new file mode 100644 index 0000000..2bd0bf5 --- /dev/null +++ b/modules/session_guard/remember_me_token.ts @@ -0,0 +1,174 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHash } from 'node:crypto' +import string from '@adonisjs/core/helpers/string' +import { Secret, base64, safeEqual } from '@adonisjs/core/helpers' + +/** + * Remember me token represents an opaque token that can be + * used to automatically login a user without asking them + * to re-login + */ +export class RememberMeToken { + /** + * Decodes a publicly shared token and return the series + * and the token value from it. + * + * Returns null when unable to decode the token because of + * invalid format or encoding. + */ + static decode(value: string): null | { identifier: string; secret: Secret } { + /** + * Ensure value is a string and starts with the prefix. + */ + if (typeof value !== 'string') { + return null + } + + /** + * Remove prefix from the rest of the token. + */ + if (!value) { + return null + } + + const [identifier, ...tokenValue] = value.split('.') + if (!identifier || tokenValue.length === 0) { + return null + } + + const decodedIdentifier = base64.urlDecode(identifier) + const decodedSecret = base64.urlDecode(tokenValue.join('.')) + if (!decodedIdentifier || !decodedSecret) { + return null + } + + return { + identifier: decodedIdentifier, + secret: new Secret(decodedSecret), + } + } + + /** + * Creates a transient token that can be shared with the persistence + * layer. + */ + static createTransientToken( + userId: string | number | BigInt, + size: number, + expiresIn: string | number + ) { + const expiresAt = new Date() + expiresAt.setSeconds(expiresAt.getSeconds() + string.seconds.parse(expiresIn)) + + return { + userId, + expiresAt, + ...this.seed(size), + } + } + + /** + * Creates a secret opaque token and its hash. + */ + static seed(size: number) { + const seed = string.random(size) + const secret = new Secret(seed) + const hash = createHash('sha256').update(secret.release()).digest('hex') + return { secret, hash } + } + + /** + * Identifer is a unique sequence to identify the + * token within database. It should be the + * primary/unique key + */ + identifier: string | number | BigInt + + /** + * Reference to the user id for whom the token + * is generated. + */ + tokenableId: string | number | BigInt + + /** + * The value is a public representation of a token. It is created + * by combining the "identifier"."secret" + */ + value?: Secret + + /** + * Hash is computed from the seed to later verify the validity + * of seed + */ + hash: string + + /** + * Date/time when the token instance was created + */ + createdAt: Date + + /** + * Date/time when the token was updated + */ + updatedAt: Date + + /** + * Timestamp at which the token will expire + */ + expiresAt: Date + + constructor(attributes: { + identifier: string | number | BigInt + tokenableId: string | number | BigInt + hash: string + createdAt: Date + updatedAt: Date + expiresAt: Date + secret?: Secret + }) { + this.identifier = attributes.identifier + this.tokenableId = attributes.tokenableId + this.hash = attributes.hash + this.createdAt = attributes.createdAt + this.updatedAt = attributes.updatedAt + this.expiresAt = attributes.expiresAt + + /** + * Compute value when secret is provided + */ + if (attributes.secret) { + this.value = new Secret( + `${base64.urlEncode(String(this.identifier))}.${base64.urlEncode( + attributes.secret.release() + )}` + ) + } + } + + /** + * Check if the token has been expired. Verifies + * the "expiresAt" timestamp with the current + * date. + * + * Tokens with no expiry never expire + */ + isExpired() { + return this.expiresAt < new Date() + } + + /** + * Verifies the value of a token against the pre-defined hash + */ + verify(secret: Secret): boolean { + const newHash = createHash('sha256').update(secret.release()).digest('hex') + return safeEqual(this.hash, newHash) + } +} diff --git a/modules/session_guard/token_providers/db.ts b/modules/session_guard/token_providers/db.ts new file mode 100644 index 0000000..689d8ab --- /dev/null +++ b/modules/session_guard/token_providers/db.ts @@ -0,0 +1,275 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Secret } from '@adonisjs/core/helpers' +import type { LucidModel } from '@adonisjs/lucid/types/model' + +import { RememberMeToken } from '../remember_me_token.js' +import type { + RememberMeTokenDbColumns, + RememberMeTokensProviderContract, + DbRememberMeTokensProviderOptions, +} from '../types.js' +import { RuntimeException } from '@adonisjs/core/exceptions' + +/** + * DbAccessTokensProvider uses lucid database service to fetch and + * persist tokens for a given user. + * + * The user must be an instance of the associated user model. + */ +export class DbRememberMeTokensProvider + implements RememberMeTokensProviderContract +{ + /** + * Create tokens provider instance for a given Lucid model + */ + static forModel( + model: DbRememberMeTokensProviderOptions['tokenableModel'], + options?: Omit, 'tokenableModel'> + ) { + return new DbRememberMeTokensProvider({ + tokenableModel: model, + ...(options || {}), + }) + } + + /** + * Duration after which the token should expire + */ + protected expiresIn: string | number + + /** + * Database table to use for querying access tokens + */ + protected table: string + + /** + * The length for the token secret. A secret is a cryptographically + * secure random string. + */ + protected tokenSecretLength: number + + constructor(protected options: DbRememberMeTokensProviderOptions) { + this.table = options.table || 'remember_me_tokens' + this.expiresIn = options.expiresIn || '2 years' + this.tokenSecretLength = options.tokenSecretLength || 40 + } + + /** + * Ensure the provided user is an instance of the user model and + * has a primary key + */ + #ensureIsPersisted(user: InstanceType) { + const model = this.options.tokenableModel + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + if (!user.$primaryKeyValue) { + throw new RuntimeException( + `Cannot use "${model.name}" model for managing remember me tokens. The value of column "${model.primaryKey}" is undefined or null` + ) + } + } + + /** + * Maps a database row to an instance token instance + */ + protected dbRowToAccessToken(dbRow: RememberMeTokenDbColumns): RememberMeToken { + return new RememberMeToken({ + identifier: dbRow.id, + tokenableId: dbRow.tokenable_id, + hash: dbRow.hash, + createdAt: + typeof dbRow.created_at === 'number' ? new Date(dbRow.created_at) : dbRow.created_at, + updatedAt: + typeof dbRow.updated_at === 'number' ? new Date(dbRow.updated_at) : dbRow.updated_at, + expiresAt: + typeof dbRow.expires_at === 'number' ? new Date(dbRow.expires_at) : dbRow.expires_at, + }) + } + + /** + * Returns a query client instance from the parent model + */ + protected async getDb() { + const model = this.options.tokenableModel + return model.$adapter.query(model).client + } + + /** + * Create a token for a user + */ + async create(user: InstanceType, expiresIn?: string | number) { + this.#ensureIsPersisted(user) + + const queryClient = await this.getDb() + + /** + * Creating a transient token. Transient token abstracts + * the logic of creating a random secure secret and its + * hash + */ + const transientToken = RememberMeToken.createTransientToken( + user.$primaryKeyValue!, + this.tokenSecretLength, + expiresIn || this.expiresIn + ) + + /** + * Row to insert inside the database. We expect exactly these + * columns to exist. + */ + const dbRow: Omit = { + tokenable_id: transientToken.userId, + hash: transientToken.hash, + created_at: new Date(), + updated_at: new Date(), + expires_at: transientToken.expiresAt, + } + + /** + * Insert data to the database. + */ + const [id] = await queryClient.table(this.table).insert(dbRow) + + /** + * Convert db row to an access token + */ + return new RememberMeToken({ + identifier: id, + tokenableId: dbRow.tokenable_id, + secret: transientToken.secret, + hash: dbRow.hash, + createdAt: dbRow.created_at, + updatedAt: dbRow.updated_at, + expiresAt: dbRow.expires_at, + }) + } + + /** + * Find a token for a user by the token id + */ + async find(user: InstanceType, identifier: string | number | BigInt) { + this.#ensureIsPersisted(user) + + const queryClient = await this.getDb() + const dbRow = await queryClient + .query() + .from(this.table) + .where({ id: identifier, tokenable_id: user.$primaryKeyValue }) + .limit(1) + .first() + + if (!dbRow) { + return null + } + + return this.dbRowToAccessToken(dbRow) + } + + /** + * Delete a token by its id + */ + async delete( + user: InstanceType, + identifier: string | number | BigInt + ): Promise { + this.#ensureIsPersisted(user) + + const queryClient = await this.getDb() + const affectedRows = await queryClient + .query() + .from(this.table) + .where({ id: identifier, tokenable_id: user.$primaryKeyValue }) + .del() + .exec() + + return affectedRows as unknown as number + } + + /** + * Returns all the tokens a given user + */ + async all(user: InstanceType) { + this.#ensureIsPersisted(user) + + const queryClient = await this.getDb() + const dbRows = await queryClient + .query() + .from(this.table) + .where({ tokenable_id: user.$primaryKeyValue }) + .orderBy('id', 'desc') + .exec() + + return dbRows.map((dbRow) => { + return this.dbRowToAccessToken(dbRow) + }) + } + + /** + * Verifies a publicly shared access token and returns an + * access token for it. + * + * Returns null when unable to verify the token or find it + * inside the storage + */ + async verify(tokenValue: Secret) { + const decodedToken = RememberMeToken.decode(tokenValue.release()) + if (!decodedToken) { + return null + } + + const db = await this.getDb() + const dbRow = await db + .query() + .from(this.table) + .where({ id: decodedToken.identifier }) + .limit(1) + .first() + + if (!dbRow) { + return null + } + + /** + * Convert to access token instance + */ + const accessToken = this.dbRowToAccessToken(dbRow) + + /** + * Ensure the token secret matches the token hash + */ + if (!accessToken.verify(decodedToken.secret) || accessToken.isExpired()) { + return null + } + + return accessToken + } + + /** + * Recycles a remember me token by deleting the old one and + * creates a new one. + * + * Ideally, the recycle should update the existing token, but we + * skip that for now and come back to it later and handle race + * conditions as well. + */ + async recycle( + user: InstanceType, + identifier: string | number | BigInt, + expiresIn?: string | number + ): Promise { + await this.delete(user, identifier) + return this.create(user, expiresIn) + } +} diff --git a/modules/session_guard/types.ts b/modules/session_guard/types.ts new file mode 100644 index 0000000..0fdbca8 --- /dev/null +++ b/modules/session_guard/types.ts @@ -0,0 +1,241 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Secret } from '@adonisjs/core/helpers' +import type { HttpContext } from '@adonisjs/core/http' +import type { Exception } from '@adonisjs/core/exceptions' +import type { LucidModel } from '@adonisjs/lucid/types/model' + +import { PROVIDER_REAL_USER } from '../../src/symbols.js' +import type { RememberMeToken } from './remember_me_token.js' + +/** + * Options accepted by the tokens provider that uses lucid + * database service to fetch and persist tokens. + */ +export type DbRememberMeTokensProviderOptions = { + /** + * The user model for which to generate tokens. Note, the model + * is not used for tokens, but is used to associate a user + * with the token + */ + tokenableModel: TokenableModel + + /** + * Database table to use for querying tokens. + * + * Defaults to "remember_me_tokens" + */ + table?: string + + /** + * The default expiry for all the tokens. You can also customize + * expiry at the time of creating a token as well. + * + * Defaults to "2 years" + */ + expiresIn?: string | number + + /** + * The length for the token secret. A secret is a cryptographically + * secure random string. + * + * Defaults to 40 + */ + tokenSecretLength?: number +} + +/** + * Remember me token providers are used verify a remember me + * token during authentication + */ +export interface RememberMeTokensProviderContract { + /** + * Create a token for a given user + */ + create(user: InstanceType, expiresIn: string | number): Promise + + /** + * Verifies the remember me token shared as cookie and returns an + * instance of remember me token + */ + verify(tokenValue: Secret): Promise + + /** + * Recycle an existing token by its id. Recycling tokens helps + * detect compromised tokens. + * https://web.archive.org/web/20130214051957/http://jaspan.com/improved_persistent_login_cookie_best_practice + */ + recycle( + user: InstanceType, + identifier: string | number | BigInt, + expiresIn: string | number + ): Promise +} + +/** + * The database columns expected at the database level + */ +export type RememberMeTokenDbColumns = { + /** + * Token primary key. It can be an integer, bigInteger or + * even a UUID or any other string based value. + * + * The id should not have ". (dots)" inside it. + */ + id: number | string | BigInt + + /** + * The user or entity for whom the token is + * generated + */ + tokenable_id: string | number | BigInt + + /** + * Token hash is used to verify the token shared + * with the user + */ + hash: string + + /** + * Timestamps + */ + created_at: Date + updated_at: Date + + /** + * The date after which the token will be considered + * expired. + */ + expires_at: Date +} + +/** + * Guard user is an adapter between the user provider + * and the guard. + * + * The guard is user provider agnostic and therefore it + * needs a adapter to known some basic info about the + * user. + */ +export type SessionGuardUser = { + getId(): string | number | BigInt + getOriginal(): RealUser +} + +/** + * The user provider used by session guard to lookup users + * during authentication + */ +export interface SessionUserProviderContract { + [PROVIDER_REAL_USER]: RealUser + + /** + * Create a user object that acts as an adapter between + * the guard and real user value. + */ + createUserForGuard(user: RealUser): Promise> + + /** + * Find a user by their id. + */ + findById(identifier: string | number | BigInt): Promise | null> + + /** + * Create a token for a given user. + */ + createRememberToken(user: RealUser, expiresIn?: string | number): Promise + + /** + * Verify a token by its publicly shared value. + */ + verifyRememberToken(tokenValue: Secret): Promise + + /** + * Recycle a token for a user by the token identifier. + */ + recycleRememberToken( + user: RealUser, + identifier: string | number | BigInt, + expiresIn?: string | number + ): Promise + + /** + * Delete a token for a user by the token identifier. + */ + deleteRemeberToken(user: RealUser, identifier: string | number | BigInt): Promise +} + +/** + * Events emitted by the session guard + */ +export type SessionGuardEvents = { + /** + * The event is emitted when login is attempted for + * a given user. + */ + 'session_auth:login_attempted': { + ctx: HttpContext + guardName: string + user: User + } + + /** + * The event is emitted when user has been logged in + * successfully + */ + 'session_auth:login_succeeded': { + ctx: HttpContext + guardName: string + user: User + sessionId: string + rememberMeToken?: RememberMeToken + } + + /** + * Attempting to authenticate the user + */ + 'session_auth:authentication_attempted': { + ctx: HttpContext + guardName: string + sessionId: string + } + + /** + * Authentication was successful + */ + 'session_auth:authentication_succeeded': { + ctx: HttpContext + guardName: string + user: User + sessionId: string + rememberMeToken?: RememberMeToken + } + + /** + * Authentication failed + */ + 'session_auth:authentication_failed': { + ctx: HttpContext + guardName: string + error: Exception + sessionId: string + } + + /** + * The event is emitted when user has been logged out + * sucessfully + */ + 'session_auth:logged_out': { + ctx: HttpContext + guardName: string + user: User | null + sessionId: string + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 133d140..0c9db19 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -105,7 +105,7 @@ export async function createTables(db: Database) { await db.connection().schema.createTable('remember_me_tokens', (table) => { table.increments() - table.integer('user_id').notNullable().unsigned() + table.integer('tokenable_id').notNullable().unsigned() table.string('hash', 80).notNullable() table.timestamp('created_at').notNullable() table.timestamp('updated_at').notNullable() diff --git a/tests/session/tokens_providers/db.spec.ts b/tests/session/tokens_providers/db.spec.ts new file mode 100644 index 0000000..41f3ccd --- /dev/null +++ b/tests/session/tokens_providers/db.spec.ts @@ -0,0 +1,514 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Secret } from '@adonisjs/core/helpers' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +import { createDatabase, createTables, timeTravel } from '../../helpers.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' +import { DbRememberMeTokensProvider } from '../../../modules/session_guard/token_providers/db.js' + +test.group('RememberMe tokens provider | DB | create', () => { + test('create token for a user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user) + assert.exists(token.identifier) + assert.instanceOf(token, RememberMeToken) + assert.equal(token.tokenableId, user.id) + assert.instanceOf(token.expiresAt, Date) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.exists(token.value) + }) + + test('define token expiry at the time of generating it', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user, '20 mins') + assert.exists(token.identifier) + assert.instanceOf(token, RememberMeToken) + assert.equal(token.tokenableId, user.id) + assert.instanceOf(token.expiresAt, Date) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.exists(token.value) + + assert.isFalse(token.isExpired()) + timeTravel(21 * 60) + assert.isTrue(token.isExpired()) + }) + + test('throw error when user id is missing', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = new User() + await assert.rejects( + () => User.rememberMeTokens.create(user), + 'Cannot use "User" model for managing remember me tokens. The value of column "id" is undefined or null' + ) + }) + + test('throw error when user is not an instance of the associated model', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + await assert.rejects( + // @ts-expect-error + () => User.rememberMeTokens.create({}), + 'Invalid user object. It must be an instance of the "User" model' + ) + }) +}) + +test.group('RememberMe tokens provider | DB | verify', () => { + test('return access token when token value is valid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user) + const freshToken = await User.rememberMeTokens.verify(new Secret(token.value!.release())) + + assert.instanceOf(freshToken, RememberMeToken) + assert.isUndefined(freshToken!.value) + assert.equal(freshToken!.hash, token.hash) + assert.equal(freshToken!.createdAt.getTime(), token.createdAt.getTime()) + assert.equal(freshToken!.expiresAt.getTime(), token.expiresAt.getTime()) + }) + + test('return null when token has been expired', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user, '20 mins') + timeTravel(21 * 60) + + const freshToken = await User.rememberMeTokens.verify(new Secret(token.value!.release())) + assert.isNull(freshToken) + }) + + test('return null when token does not exists', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user) + await User.rememberMeTokens.delete(user, token.identifier) + + const freshToken = await User.rememberMeTokens.verify(new Secret(token.value!.release())) + assert.isNull(freshToken) + }) + + test('return null when token value is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const freshToken = await User.rememberMeTokens.verify(new Secret('foo.bar')) + assert.isNull(freshToken) + }) + + test('return null when token secret is invalid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user) + const value = token.value!.release() + const [identifier] = value.split('.') + + const freshToken = await User.rememberMeTokens.verify(new Secret(`${identifier}.bar`)) + assert.isNull(freshToken) + }) +}) + +test.group('RememberMe tokens provider | DB | find', () => { + test('get token by id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user) + const freshToken = await User.rememberMeTokens.find(user, token.identifier) + + assert.exists(freshToken!.identifier) + assert.instanceOf(freshToken, RememberMeToken) + assert.equal(freshToken!.tokenableId, user.id) + assert.instanceOf(freshToken!.expiresAt, Date) + assert.instanceOf(freshToken!.createdAt, Date) + assert.instanceOf(freshToken!.updatedAt, Date) + assert.isDefined(freshToken!.hash) + assert.isUndefined(freshToken!.value) + }) + + test('get expired tokens as well', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user, '20 mins') + timeTravel(21 * 60) + const freshToken = await User.rememberMeTokens.find(user, token.identifier) + + assert.exists(freshToken!.identifier) + assert.instanceOf(freshToken, RememberMeToken) + assert.equal(freshToken!.tokenableId, user.id) + assert.instanceOf(freshToken!.expiresAt, Date) + assert.instanceOf(freshToken!.createdAt, Date) + assert.instanceOf(freshToken!.updatedAt, Date) + assert.isDefined(freshToken!.hash) + assert.isUndefined(freshToken!.value) + assert.isTrue(freshToken!.isExpired()) + }) + + test('get null when token is missing', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const freshToken = await User.rememberMeTokens.find(user, 'foo') + assert.isNull(freshToken) + }) +}) + +test.group('RememberMe tokens provider | DB | all', () => { + test('get list of all tokens order by id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + await User.rememberMeTokens.create(user, '20 mins') + await User.rememberMeTokens.create(user) + timeTravel(21 * 60) + const tokens = await User.rememberMeTokens.all(user) + + assert.lengthOf(tokens, 2) + + assert.exists(tokens[0].identifier) + assert.instanceOf(tokens[0], RememberMeToken) + assert.equal(tokens[0].tokenableId, user.id) + assert.instanceOf(tokens[0].expiresAt, Date) + assert.instanceOf(tokens[0].createdAt, Date) + assert.instanceOf(tokens[0].updatedAt, Date) + assert.isDefined(tokens[0].hash) + assert.isUndefined(tokens[0].value) + assert.isFalse(tokens[0].isExpired()) + + assert.exists(tokens[1].identifier) + assert.equal(tokens[1].tokenableId, user.id) + assert.instanceOf(tokens[1].expiresAt, Date) + assert.instanceOf(tokens[1].createdAt, Date) + assert.instanceOf(tokens[1].updatedAt, Date) + assert.isDefined(tokens[1].hash) + assert.isUndefined(tokens[1].value) + assert.isTrue(tokens[1].isExpired()) + }) +}) + +test.group('RememberMe tokens provider | DB | recycle', () => { + test('delete token on recycle', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await User.rememberMeTokens.create(user, '20 mins') + const freshToken = await User.rememberMeTokens.recycle(user, token.identifier) + + assert.isNull(await User.rememberMeTokens.find(user, token.identifier)) + assert.isNotNull(await User.rememberMeTokens.find(user, freshToken.identifier)) + }) +}) From c0da6013756fd278a383e60aa34b9ca87cd42a80 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 12:32:34 +0530 Subject: [PATCH 91/96] test: add tests for remember me token --- tests/session/remember_me_token.spec.ts | 155 ++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/session/remember_me_token.spec.ts diff --git a/tests/session/remember_me_token.spec.ts b/tests/session/remember_me_token.spec.ts new file mode 100644 index 0000000..9ad179e --- /dev/null +++ b/tests/session/remember_me_token.spec.ts @@ -0,0 +1,155 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Secret, base64 } from '@poppinss/utils' + +import { freezeTime } from '../helpers.js' +import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' + +test.group('RememberMeToken token | decode', () => { + test('decode "{input}" as token') + .with([ + { + input: null, + output: null, + }, + { + input: '', + output: null, + }, + { + input: '..', + output: null, + }, + { + input: 'foobar', + output: null, + }, + { + input: 'foo.baz', + output: null, + }, + { + input: `bar.${base64.urlEncode('baz')}`, + output: null, + }, + { + input: `${base64.urlEncode('baz')}.bar`, + output: null, + }, + { + input: `${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`, + output: { + identifier: 'bar', + secret: 'baz', + }, + }, + ]) + .run(({ assert }, { input, output }) => { + const decoded = RememberMeToken.decode(input as string) + if (!decoded) { + assert.deepEqual(decoded, output) + } else { + assert.deepEqual( + { identifier: decoded.identifier, secret: decoded.secret.release() }, + output + ) + } + }) +}) + +test.group('RememberMeToken token | create', () => { + test('create a transient token', ({ assert }) => { + freezeTime() + const date = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(date.getSeconds() + 60 * 20) + + const token = RememberMeToken.createTransientToken(1, 40, '20 mins') + assert.equal(token.userId, 1) + assert.exists(token.hash) + assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) + assert.instanceOf(token.secret, Secret) + }) + + test('create token from persisted information', ({ assert }) => { + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) + + const token = new RememberMeToken({ + identifier: '12', + tokenableId: 1, + hash: '1234', + createdAt, + updatedAt, + expiresAt, + }) + + assert.equal(token.identifier, '12') + assert.equal(token.hash, '1234') + assert.equal(token.tokenableId, 1) + assert.equal(token.createdAt.getTime(), createdAt.getTime()) + assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) + assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) + + assert.isUndefined(token.value) + assert.isFalse(token.isExpired()) + }) + + test('create token with a secret', ({ assert }) => { + const createdAt = new Date() + const updatedAt = new Date() + const expiresAt = new Date() + expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) + + const transientToken = RememberMeToken.createTransientToken(1, 40, '20 mins') + + const token = new RememberMeToken({ + identifier: '12', + tokenableId: 1, + hash: transientToken.hash, + createdAt, + updatedAt, + expiresAt, + secret: transientToken.secret, + }) + + const decoded = RememberMeToken.decode(token.value!.release()) + + assert.equal(token.identifier, '12') + assert.equal(token.tokenableId, 1) + assert.equal(token.hash, transientToken.hash) + assert.instanceOf(token.value, Secret) + assert.isTrue(token.verify(transientToken.secret)) + assert.isTrue(token.verify(decoded!.secret)) + assert.equal(token.createdAt.getTime(), createdAt.getTime()) + assert.equal(token.updatedAt.getTime(), updatedAt.getTime()) + assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) + assert.isFalse(token.isExpired()) + }) + + test('verify token hash', ({ assert }) => { + const transientToken = RememberMeToken.createTransientToken(1, 40, '20 mins') + + const token = new RememberMeToken({ + identifier: '12', + tokenableId: 1, + hash: transientToken.hash, + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(), + secret: transientToken.secret, + }) + + assert.isTrue(token.verify(transientToken.secret)) + }) +}) From 06e8f1d6aae3389fca391e18e5377c9b24b13d2d Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 16:17:51 +0530 Subject: [PATCH 92/96] feat: implement session guard providers and authenticator --- factories/access_tokens/main.ts | 4 +- factories/session/main.ts | 182 +++++ .../access_tokens_guard/token_providers/db.ts | 2 +- modules/access_tokens_guard/types.ts | 4 +- .../user_providers/lucid.ts | 15 +- modules/session_guard/guard.ts | 126 ++-- modules/session_guard/remember_me_token.ts | 2 - modules/session_guard/token_providers/db.ts | 38 +- modules/session_guard/types.ts | 81 +- modules/session_guard/user_providers/lucid.ts | 161 ++++ .../user_providers/lucid.spec.ts | 10 +- tests/session/guard/authenticate.spec.ts | 693 ++++++++++++++++++ tests/session/tokens_providers/db.spec.ts | 53 +- tests/session/user_providers/lucid.spec.ts | 304 ++++++++ 14 files changed, 1528 insertions(+), 147 deletions(-) create mode 100644 factories/session/main.ts create mode 100644 modules/session_guard/user_providers/lucid.ts create mode 100644 tests/session/guard/authenticate.spec.ts create mode 100644 tests/session/user_providers/lucid.spec.ts diff --git a/factories/access_tokens/main.ts b/factories/access_tokens/main.ts index f7a6c33..7f1f008 100644 --- a/factories/access_tokens/main.ts +++ b/factories/access_tokens/main.ts @@ -43,8 +43,8 @@ const users: AccessTokensFakeUser[] = [ ] /** - * Implementation of a user provider to be used by session guard for - * authentication. Used for testing. + * Implementation of a user provider to be used by access tokens + * guard for authentication. Used for testing. * * @note * Should not be exported to the outside world diff --git a/factories/session/main.ts b/factories/session/main.ts new file mode 100644 index 0000000..dda5638 --- /dev/null +++ b/factories/session/main.ts @@ -0,0 +1,182 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { PROVIDER_REAL_USER } from '../../src/symbols.js' +import { + RememberMeTokenDbColumns, + SessionUserProviderContract, + SessionWithTokensUserProviderContract, +} from '../../modules/session_guard/types.js' +import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' +import stringHelpers from '@adonisjs/core/helpers/string' +import { Secret } from '@adonisjs/core/helpers' + +/** + * Representation of a fake user used to test + * the session guard. + * + * @note + * Should not be exported to the outside world + */ +export type SessionFakeUser = { + id: number + email: string + password: string +} + +/** + * Collection of dummy users + */ +const users: SessionFakeUser[] = [ + { + id: 1, + email: 'virk@adonisjs.com', + password: 'secret', + }, + { + id: 2, + email: 'romain@adonisjs.com', + password: 'secret', + }, +] + +/** + * Implementation of a user provider to be used by session guard for + * authentication. Used for testing. + * + * @note + * Should not be exported to the outside world + */ +export class SessionFakeUserProvider implements SessionUserProviderContract { + declare [PROVIDER_REAL_USER]: SessionFakeUser + + /** + * Creates the adapter user for the guard + */ + async createUserForGuard(user: SessionFakeUser) { + return { + getId() { + return user.id + }, + getOriginal() { + return user + }, + } + } + + /** + * Finds a user id + */ + async findById(id: number) { + const user = users.find(({ id: userId }) => userId === id) + if (!user) { + return null + } + + return this.createUserForGuard(user) + } +} + +/** + * Implementation with tokens methods as well + * + * @note + * Should not be exported to the outside world + */ +export class SessionFakeUserWithTokensProvider + extends SessionFakeUserProvider + implements SessionWithTokensUserProviderContract +{ + tokens: RememberMeTokenDbColumns[] = [] + + /** + * Creates a remember me token for a given user + */ + async createRememberToken( + user: SessionFakeUser, + expiresIn: string | number + ): Promise { + const transientToken = RememberMeToken.createTransientToken(user.id, 40, expiresIn) + const id = stringHelpers.random(15) + const createdAt = new Date() + const updatedAt = new Date() + + this.tokens.push({ + id, + tokenable_id: user.id, + hash: transientToken.hash, + created_at: createdAt, + updated_at: updatedAt, + expires_at: transientToken.expiresAt, + }) + + return new RememberMeToken({ + identifier: id, + tokenableId: user.id, + hash: transientToken.hash, + secret: transientToken.secret, + createdAt, + updatedAt, + expiresAt: transientToken.expiresAt, + }) + } + + /** + * Deletes token by the token id + */ + async deleteRemeberToken( + _: SessionFakeUser, + tokenIdentifier: string | number | BigInt + ): Promise { + this.tokens = this.tokens.filter((token) => token.id !== tokenIdentifier) + return 1 + } + + /** + * Verifies a given token + */ + async verifyRememberToken(tokenValue: Secret): Promise { + const decodedToken = RememberMeToken.decode(tokenValue.release()) + if (!decodedToken) { + return null + } + + const token = this.tokens.find(({ id }) => id === decodedToken.identifier) + if (!token) { + return null + } + + const rememberMeToken = new RememberMeToken({ + identifier: token.id, + tokenableId: token.tokenable_id, + hash: token.hash, + createdAt: token.created_at, + updatedAt: token.updated_at, + expiresAt: token.expires_at, + }) + + if (!rememberMeToken.verify(decodedToken.secret) || rememberMeToken.isExpired()) { + return null + } + + return rememberMeToken + } + + /** + * Recycles token by deleting the old one and creating a new one + */ + async recycleRememberToken( + user: SessionFakeUser, + tokenIdentifier: string | number | BigInt, + expiresIn: string | number + ): Promise { + await this.deleteRemeberToken(user, tokenIdentifier) + return this.createRememberToken(user, expiresIn) + } +} diff --git a/modules/access_tokens_guard/token_providers/db.ts b/modules/access_tokens_guard/token_providers/db.ts index 3f47982..b732c3a 100644 --- a/modules/access_tokens_guard/token_providers/db.ts +++ b/modules/access_tokens_guard/token_providers/db.ts @@ -8,6 +8,7 @@ */ import type { Secret } from '@adonisjs/core/helpers' +import { RuntimeException } from '@adonisjs/core/exceptions' import type { LucidModel } from '@adonisjs/lucid/types/model' import { AccessToken } from '../access_token.js' @@ -16,7 +17,6 @@ import type { AccessTokensProviderContract, DbAccessTokensProviderOptions, } from '../types.js' -import { RuntimeException } from '@adonisjs/core/exceptions' /** * DbAccessTokensProvider uses lucid database service to fetch and diff --git a/modules/access_tokens_guard/types.ts b/modules/access_tokens_guard/types.ts index a3a87b1..050fee0 100644 --- a/modules/access_tokens_guard/types.ts +++ b/modules/access_tokens_guard/types.ts @@ -211,9 +211,9 @@ export interface AccessTokensUserProviderContract { ): Promise /** - * Find a user by their id. + * Find a user by the user id. */ - findById(tokenableId: string | number | BigInt): Promise | null> + findById(identifier: string | number | BigInt): Promise | null> /** * Verify a token by its publicly shared value. diff --git a/modules/access_tokens_guard/user_providers/lucid.ts b/modules/access_tokens_guard/user_providers/lucid.ts index f6402e9..c9de3f2 100644 --- a/modules/access_tokens_guard/user_providers/lucid.ts +++ b/modules/access_tokens_guard/user_providers/lucid.ts @@ -9,14 +9,15 @@ import { Secret } from '@adonisjs/core/helpers' import { RuntimeException } from '@adonisjs/core/exceptions' + +import { AccessToken } from '../access_token.js' import { PROVIDER_REAL_USER } from '../../../src/symbols.js' import type { + LucidTokenable, AccessTokensGuardUser, - AccessTokensLucidUserProviderOptions, AccessTokensUserProviderContract, - LucidTokenable, + AccessTokensLucidUserProviderOptions, } from '../types.js' -import { AccessToken } from '../access_token.js' /** * Uses a lucid model to verify access tokens and find a user during @@ -63,7 +64,7 @@ export class AccessTokensLucidUserProvider< if (!model[this.options.tokens]) { throw new RuntimeException( - `Cannot use "${model.name}" for verifying access tokens. Make sure to assign a token provider to the model.` + `Cannot use "${model.name}" model for verifying access tokens. Make sure to assign a token provider to the model.` ) } @@ -125,13 +126,13 @@ export class AccessTokensLucidUserProvider< } /** - * Finds a user by their primary key value + * Finds a user by the user id */ async findById( - tokenableId: string | number | BigInt + identifier: string | number | BigInt ): Promise> | null> { const model = await this.getModel() - const user = await model.find(tokenableId) + const user = await model.find(identifier) if (!user) { return null diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts index f856d7e..af7bd44 100644 --- a/modules/session_guard/guard.ts +++ b/modules/session_guard/guard.ts @@ -16,14 +16,23 @@ import { RememberMeToken } from './remember_me_token.js' import { E_UNAUTHORIZED_ACCESS } from '../../src/errors.js' import type { AuthClientResponse, GuardContract } from '../../src/types.js' import { GUARD_KNOWN_EVENTS, PROVIDER_REAL_USER } from '../../src/symbols.js' -import type { SessionGuardEvents, SessionUserProviderContract } from './types.js' +import type { + SessionGuardEvents, + SessionGuardOptions, + SessionUserProviderContract, + SessionWithTokensUserProviderContract, +} from './types.js' /** * Session guard uses AdonisJS session store to track logged-in * user information. */ -export class SessionGuard> - implements GuardContract +export class SessionGuard< + UseRememberTokens extends boolean, + UserProvider extends UseRememberTokens extends true + ? SessionWithTokensUserProviderContract + : SessionUserProviderContract, +> implements GuardContract { /** * Events emitted by the guard @@ -40,6 +49,11 @@ export class SessionGuard> + /** * Provider to lookup user details */ @@ -61,6 +75,12 @@ export class SessionGuard, emitter: EmitterLike>, userProvider: UserProvider ) { this.#name = name this.#ctx = ctx + this.#options = { rememberMeTokensAge: '2 years', ...options } this.#emitter = emitter this.#userProvider = userProvider } @@ -140,10 +156,14 @@ export class SessionGuard) { + this.#ctx.response.encryptedCookie(this.rememberMeKeyName, value.release(), { + maxAge: this.#options.rememberMeTokensAge, + httpOnly: true, + }) + } + /** * Authenticates the user using its id read from the session * store. @@ -190,35 +229,13 @@ export class SessionGuard + /** * Verify the token using the user provider. */ - const token = await this.#userProvider.verifyRememberToken(new Secret(rememberMeCookie)) + const token = await userProvider.verifyRememberToken(new Secret(rememberMeCookie)) if (!token) { throw this.#authenticationFailed(sessionId) } @@ -238,7 +263,7 @@ export class SessionGuard { /** - * Return early when authentication has already - * been attempted + * Return early when authentication has already been + * attempted */ if (this.authenticationAttempted) { return this.getUserOrFail() @@ -312,14 +342,20 @@ export class SessionGuard } /** - * Duration after which the token should expire - */ - protected expiresIn: string | number - - /** - * Database table to use for querying access tokens + * Database table to use for querying remember me tokens */ protected table: string @@ -58,7 +53,6 @@ export class DbRememberMeTokensProvider constructor(protected options: DbRememberMeTokensProviderOptions) { this.table = options.table || 'remember_me_tokens' - this.expiresIn = options.expiresIn || '2 years' this.tokenSecretLength = options.tokenSecretLength || 40 } @@ -84,7 +78,7 @@ export class DbRememberMeTokensProvider /** * Maps a database row to an instance token instance */ - protected dbRowToAccessToken(dbRow: RememberMeTokenDbColumns): RememberMeToken { + protected dbRowToRememberMeToken(dbRow: RememberMeTokenDbColumns): RememberMeToken { return new RememberMeToken({ identifier: dbRow.id, tokenableId: dbRow.tokenable_id, @@ -109,7 +103,7 @@ export class DbRememberMeTokensProvider /** * Create a token for a user */ - async create(user: InstanceType, expiresIn?: string | number) { + async create(user: InstanceType, expiresIn: string | number) { this.#ensureIsPersisted(user) const queryClient = await this.getDb() @@ -122,7 +116,7 @@ export class DbRememberMeTokensProvider const transientToken = RememberMeToken.createTransientToken( user.$primaryKeyValue!, this.tokenSecretLength, - expiresIn || this.expiresIn + expiresIn ) /** @@ -143,7 +137,7 @@ export class DbRememberMeTokensProvider const [id] = await queryClient.table(this.table).insert(dbRow) /** - * Convert db row to an access token + * Convert db row to a remember token */ return new RememberMeToken({ identifier: id, @@ -174,7 +168,7 @@ export class DbRememberMeTokensProvider return null } - return this.dbRowToAccessToken(dbRow) + return this.dbRowToRememberMeToken(dbRow) } /** @@ -212,13 +206,13 @@ export class DbRememberMeTokensProvider .exec() return dbRows.map((dbRow) => { - return this.dbRowToAccessToken(dbRow) + return this.dbRowToRememberMeToken(dbRow) }) } /** - * Verifies a publicly shared access token and returns an - * access token for it. + * Verifies a publicly shared remember me token and returns an + * RememberMeToken for it. * * Returns null when unable to verify the token or find it * inside the storage @@ -242,18 +236,18 @@ export class DbRememberMeTokensProvider } /** - * Convert to access token instance + * Convert to remember me token instance */ - const accessToken = this.dbRowToAccessToken(dbRow) + const rememberMeToken = this.dbRowToRememberMeToken(dbRow) /** * Ensure the token secret matches the token hash */ - if (!accessToken.verify(decodedToken.secret) || accessToken.isExpired()) { + if (!rememberMeToken.verify(decodedToken.secret) || rememberMeToken.isExpired()) { return null } - return accessToken + return rememberMeToken } /** @@ -267,7 +261,7 @@ export class DbRememberMeTokensProvider async recycle( user: InstanceType, identifier: string | number | BigInt, - expiresIn?: string | number + expiresIn: string | number ): Promise { await this.delete(user, identifier) return this.create(user, expiresIn) diff --git a/modules/session_guard/types.ts b/modules/session_guard/types.ts index 0fdbca8..6c6b62a 100644 --- a/modules/session_guard/types.ts +++ b/modules/session_guard/types.ts @@ -34,14 +34,6 @@ export type DbRememberMeTokensProviderOptions */ table?: string - /** - * The default expiry for all the tokens. You can also customize - * expiry at the time of creating a token as well. - * - * Defaults to "2 years" - */ - expiresIn?: string | number - /** * The length for the token secret. A secret is a cryptographically * secure random string. @@ -67,6 +59,11 @@ export interface RememberMeTokensProviderContract */ verify(tokenValue: Secret): Promise + /** + * Delete token for a user by the token identifier. + */ + delete(user: InstanceType, identifier: string | number | BigInt): Promise + /** * Recycle an existing token by its id. Recycling tokens helps * detect compromised tokens. @@ -79,6 +76,26 @@ export interface RememberMeTokensProviderContract ): Promise } +/** + * A lucid model with a tokens provider to verify remember me tokens during + * authentication + */ +export type LucidAuthenticatable = LucidModel & { + rememberMeTokens?: RememberMeTokensProviderContract +} + +/** + * Options accepted by the user provider that uses a lucid + * model to lookup a user during authentication and verify + * tokens + */ +export type SessionLucidUserProviderOptions = { + /** + * The model to use for users lookup + */ + model: () => Promise<{ default: Model }> +} + /** * The database columns expected at the database level */ @@ -146,30 +163,62 @@ export interface SessionUserProviderContract { * Find a user by their id. */ findById(identifier: string | number | BigInt): Promise | null> +} +/** + * The user provider used by session guard with support for tokens + */ +export interface SessionWithTokensUserProviderContract + extends SessionUserProviderContract { /** - * Create a token for a given user. + * Create a token for a given user. Must be implemented when + * "supportsRememberMeTokens" flag is true */ - createRememberToken(user: RealUser, expiresIn?: string | number): Promise + createRememberToken(user: RealUser, expiresIn: string | number): Promise /** - * Verify a token by its publicly shared value. + * Verify a token by its publicly shared value. Must be implemented when + * "supportsRememberMeTokens" flag is true */ verifyRememberToken(tokenValue: Secret): Promise /** - * Recycle a token for a user by the token identifier. + * Recycle a token for a user by the token identifier. Must be + * implemented when "supportsRememberMeTokens" flag is true */ recycleRememberToken( user: RealUser, - identifier: string | number | BigInt, - expiresIn?: string | number + tokenIdentifier: string | number | BigInt, + expiresIn: string | number ): Promise /** - * Delete a token for a user by the token identifier. + * Delete a token for a user by the token identifier. Must be + * implemented when "supportsRememberMeTokens" flag is true + */ + deleteRemeberToken(user: RealUser, tokenIdentifier: string | number | BigInt): Promise +} + +/** + * Options accepted by the session guard + */ +export type SessionGuardOptions = { + /** + * Whether or not use remember me tokens during authentication + * and login. + * + * If enabled, the provided user provider must implement the APIs + * needed to manage remember me tokens + */ + useRememberMeTokens: UseRememberTokens + + /** + * The age of remember me tokens after which they + * should expire. + * + * Defaults to "2 years" */ - deleteRemeberToken(user: RealUser, identifier: string | number | BigInt): Promise + rememberMeTokensAge?: string | number } /** diff --git a/modules/session_guard/user_providers/lucid.ts b/modules/session_guard/user_providers/lucid.ts new file mode 100644 index 0000000..0993a01 --- /dev/null +++ b/modules/session_guard/user_providers/lucid.ts @@ -0,0 +1,161 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Secret } from '@adonisjs/core/helpers' +import { RuntimeException } from '@adonisjs/core/exceptions' + +import { RememberMeToken } from '../remember_me_token.js' +import { PROVIDER_REAL_USER } from '../../../src/symbols.js' +import type { + SessionGuardUser, + LucidAuthenticatable, + SessionLucidUserProviderOptions, + SessionUserProviderContract, +} from '../types.js' + +/** + * Uses a lucid model to verify access tokens and find a user during + * authentication + */ +export class SessionLucidUserProvider + implements SessionUserProviderContract> +{ + declare [PROVIDER_REAL_USER]: InstanceType + + /** + * Reference to the lazily imported model + */ + protected model?: UserModel + + constructor( + /** + * Lucid provider options + */ + protected options: SessionLucidUserProviderOptions + ) {} + + /** + * Imports the model from the provider, returns and caches it + * for further operations. + */ + protected async getModel() { + if (this.model) { + return this.model + } + + const importedModel = await this.options.model() + this.model = importedModel.default + return this.model + } + + /** + * Returns the tokens provider associated with the user model + */ + protected async getTokensProvider() { + const model = await this.getModel() + + if (!model.rememberMeTokens) { + throw new RuntimeException( + `Cannot use "${model.name}" model for verifying remember me tokens. Make sure to assign a token provider to the model.` + ) + } + + return model.rememberMeTokens + } + + /** + * Creates an adapter user for the guard + */ + async createUserForGuard( + user: InstanceType + ): Promise>> { + const model = await this.getModel() + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + return { + getId() { + /** + * Ensure user has a primary key + */ + if (!user.$primaryKeyValue) { + throw new RuntimeException( + `Cannot use "${model.name}" model for authentication. The value of column "${model.primaryKey}" is undefined or null` + ) + } + + return user.$primaryKeyValue + }, + getOriginal() { + return user + }, + } + } + + /** + * Finds a user by their primary key value + */ + async findById( + identifier: string | number | BigInt + ): Promise> | null> { + const model = await this.getModel() + const user = await model.find(identifier) + + if (!user) { + return null + } + + return this.createUserForGuard(user) + } + + /** + * Creates a remember token for a given user + */ + async createRememberToken( + user: InstanceType, + expiresIn: string | number + ): Promise { + const tokensProvider = await this.getTokensProvider() + return tokensProvider.create(user, expiresIn) + } + + /** + * Verify a token by its publicly shared value + */ + async verifyRememberToken(tokenValue: Secret): Promise { + const tokensProvider = await this.getTokensProvider() + return tokensProvider.verify(tokenValue) + } + + /** + * Delete a token for a user by the token identifier + */ + async deleteRemeberToken( + user: InstanceType, + identifier: string | number | BigInt + ): Promise { + const tokensProvider = await this.getTokensProvider() + return tokensProvider.delete(user, identifier) + } + + /** + * Recycle a token for a user by the token identifier + */ + async recycleRememberToken( + user: InstanceType, + identifier: string | number | BigInt, + expiresIn: string | number + ) { + const tokensProvider = await this.getTokensProvider() + return tokensProvider.recycle(user, identifier, expiresIn) + } +} diff --git a/tests/access_tokens/user_providers/lucid.spec.ts b/tests/access_tokens/user_providers/lucid.spec.ts index a944ace..c708257 100644 --- a/tests/access_tokens/user_providers/lucid.spec.ts +++ b/tests/access_tokens/user_providers/lucid.spec.ts @@ -16,7 +16,7 @@ import { AccessToken } from '../../../modules/access_tokens_guard/access_token.j import { DbAccessTokensProvider } from '../../../modules/access_tokens_guard/token_providers/db.js' import { AccessTokensLucidUserProvider } from '../../../modules/access_tokens_guard/user_providers/lucid.js' -test.group('Access user provider | Lucid', () => { +test.group('Access tokens user provider | Lucid', () => { test('throw error when user does not implement a token provider', async ({ assert }) => { const db = await createDatabase() await createTables(db) @@ -46,12 +46,12 @@ test.group('Access user provider | Lucid', () => { await assert.rejects( () => userProvider.verifyToken(new Secret('foo')), - 'Cannot use "User" for verifying access tokens. Make sure to assign a token provider to the model.' + 'Cannot use "User" model for verifying access tokens. Make sure to assign a token provider to the model.' ) }) }) -test.group('Access user provider | Lucid | verify', () => { +test.group('Access tokens user provider | Lucid | verify', () => { test('return access token when it is valid', async ({ assert }) => { const db = await createDatabase() await createTables(db) @@ -98,7 +98,7 @@ test.group('Access user provider | Lucid | verify', () => { }) }) -test.group('Access user provider | Lucid | findById', () => { +test.group('Access tokens user provider | Lucid | findById', () => { test('find user by id', async ({ assert }) => { const db = await createDatabase() await createTables(db) @@ -185,7 +185,7 @@ test.group('Access user provider | Lucid | findById', () => { }) }) -test.group('Access user provider | Lucid | createUserForGuard', () => { +test.group('Access tokens user provider | Lucid | createUserForGuard', () => { test('throw error via getId when user does not have an id', async ({ assert }) => { const db = await createDatabase() await createTables(db) diff --git a/tests/session/guard/authenticate.spec.ts b/tests/session/guard/authenticate.spec.ts new file mode 100644 index 0000000..bb82888 --- /dev/null +++ b/tests/session/guard/authenticate.spec.ts @@ -0,0 +1,693 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { SessionGuard } from '../../../modules/session_guard/guard.js' +import { SessionGuardEvents } from '../../../modules/session_guard/types.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' +import { createEmitter, defineCookies, pEvent, parseCookies, timeTravel } from '../../helpers.js' +import { + SessionFakeUser, + SessionFakeUserProvider, + SessionFakeUserWithTokensProvider, +} from '../../../factories/session/main.js' + +test.group('Session guard | authenticate via session', () => { + test('return user when request has a valid session', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const user = await userProvider.findById(1) + ctx.session.put('auth_web', user!.getId()) + + const [attempted, succeeded, authenticatedUser] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_succeeded'), + guard.authenticate(), + ]) + + expectTypeOf(authenticatedUser).toEqualTypeOf() + expectTypeOf(guard.user).toEqualTypeOf() + expectTypeOf(succeeded!.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(succeeded!.guardName, 'web') + assert.equal(succeeded!.sessionId, ctx.session.sessionId) + assert.deepEqual(succeeded!.user, authenticatedUser) + + assert.deepEqual(guard.user, authenticatedUser) + assert.deepEqual(guard.getUserOrFail(), authenticatedUser) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + }) + + test('throw error when session does not exist', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const [attempted, failed] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_failed'), + (async () => { + try { + await guard.authenticate() + } catch {} + })(), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(failed!.guardName, 'web') + assert.equal(failed!.sessionId, ctx.session.sessionId) + assert.equal(failed!.error.message, 'Invalid or expired user session') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + }) + + test('throw error when session user does not exists', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + ctx.session.put('auth_web', 20) + + const [attempted, failed] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_failed'), + (async () => { + try { + await guard.authenticate() + } catch {} + })(), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(failed!.guardName, 'web') + assert.equal(failed!.sessionId, ctx.session.sessionId) + assert.equal(failed!.error.message, 'Invalid or expired user session') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + }) +}) + +test.group('Session guard | authenticate via remember token', () => { + test('return user when valid remember token exists', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const [attempted, succeeded, authenticatedUser] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_succeeded'), + guard.authenticate(), + ]) + + expectTypeOf(authenticatedUser).toEqualTypeOf() + expectTypeOf(guard.user).toEqualTypeOf() + expectTypeOf(succeeded!.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(succeeded!.guardName, 'web') + assert.deepEqual(succeeded!.user, authenticatedUser) + + assert.deepEqual(guard.user, authenticatedUser) + assert.deepEqual(guard.getUserOrFail(), authenticatedUser) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isTrue(guard.viaRemember) + assert.isTrue(guard.attemptedViaRemember) + }) + + test('do not attempt to use remember token when session id exists but for non-existing user', async ({ + assert, + expectTypeOf, + }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + ctx.session.put('auth_web', 20) + + const [attempted, failed] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_failed'), + (async () => { + try { + await guard.authenticate() + } catch {} + })(), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(failed!.guardName, 'web') + assert.equal(failed!.sessionId, ctx.session.sessionId) + assert.equal(failed!.error.message, 'Invalid or expired user session') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + }) + + test('do not attempt to use remember token when remember tokens have been disabled', async ({ + assert, + expectTypeOf, + }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const [attempted, failed] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_failed'), + (async () => { + try { + await guard.authenticate() + } catch {} + })(), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(failed!.guardName, 'web') + assert.equal(failed!.sessionId, ctx.session.sessionId) + assert.equal(failed!.error.message, 'Invalid or expired user session') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + }) + + test('return error when token has been expired', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + /** + * The token will expire in 20 minutes + */ + timeTravel(21 * 60) + + const [attempted, failed] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_failed'), + (async () => { + try { + await guard.authenticate() + } catch {} + })(), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(failed!.guardName, 'web') + assert.equal(failed!.sessionId, ctx.session.sessionId) + assert.equal(failed!.error.message, 'Invalid or expired user session') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isTrue(guard.attemptedViaRemember) + }) + + test('return error when token does not exist', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + /** + * Deleting token + */ + await userProvider.deleteRemeberToken(user!.getOriginal(), token.identifier) + + const [attempted, failed] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_failed'), + (async () => { + try { + await guard.authenticate() + } catch {} + })(), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(failed!.guardName, 'web') + assert.equal(failed!.sessionId, ctx.session.sessionId) + assert.equal(failed!.error.message, 'Invalid or expired user session') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isTrue(guard.attemptedViaRemember) + }) + + test('return error when user for the token does not exist', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const token = await userProvider.createRememberToken( + { id: 20, email: 'foo@bar.com', password: 'secret' }, + '20 mins' + ) + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const [attempted, failed] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_failed'), + (async () => { + try { + await guard.authenticate() + } catch {} + })(), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(failed!.guardName, 'web') + assert.equal(failed!.sessionId, ctx.session.sessionId) + assert.equal(failed!.error.message, 'Invalid or expired user session') + + assert.isUndefined(guard.user) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isTrue(guard.attemptedViaRemember) + }) + + test('recycle token after use', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const [attempted, succeeded, authenticatedUser] = await Promise.all([ + pEvent(emitter, 'session_auth:authentication_attempted'), + pEvent(emitter, 'session_auth:authentication_succeeded'), + guard.authenticate(), + ]) + + expectTypeOf(authenticatedUser).toEqualTypeOf() + expectTypeOf(guard.user).toEqualTypeOf() + expectTypeOf(succeeded!.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(succeeded!.guardName, 'web') + assert.deepEqual(succeeded!.user, authenticatedUser) + + assert.deepEqual(guard.user, authenticatedUser) + assert.deepEqual(guard.getUserOrFail(), authenticatedUser) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isTrue(guard.viaRemember) + assert.isTrue(guard.attemptedViaRemember) + + assert.lengthOf(userProvider.tokens, 1) + assert.notEqual(userProvider.tokens[0].id, token.identifier) + assert.notEqual(userProvider.tokens[0].hash, token.hash) + assert.notEqual(userProvider.tokens[0].created_at.getTime(), token.createdAt.getTime()) + assert.notEqual(userProvider.tokens[0].updated_at.getTime(), token.updatedAt.getTime()) + assert.notEqual(userProvider.tokens[0].expires_at.getTime(), token.expiresAt.getTime()) + assert.equal(userProvider.tokens[0].tokenable_id, token.tokenableId) + + const responseCookies = parseCookies(ctx.response.getHeader('set-cookie') as string) + assert.equal( + RememberMeToken.decode(responseCookies.remember_web.value)?.identifier, + userProvider.tokens[0].id + ) + }) +}) + +test.group('Session guard | check', () => { + test('return true when valid session exists', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const user = await userProvider.findById(1) + ctx.session.put('auth_web', user!.getId()) + + const isAuthenticated = await guard.check() + + expectTypeOf(isAuthenticated).toEqualTypeOf() + expectTypeOf(guard.user).toEqualTypeOf() + + assert.isTrue(isAuthenticated) + assert.deepEqual(guard.getUserOrFail(), user!.getOriginal()) + assert.isTrue(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + }) + + test('return false when valid session does not exists', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const isAuthenticated = await guard.check() + + expectTypeOf(isAuthenticated).toEqualTypeOf() + expectTypeOf(guard.user).toEqualTypeOf() + + assert.isFalse(isAuthenticated) + assert.throws(() => guard.getUserOrFail(), 'Invalid or expired user session') + assert.isFalse(guard.isAuthenticated) + assert.isTrue(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + }) +}) + +test.group('Session guard | authenticateAsClient', () => { + test('return session info for a given user', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const response = await guard.authenticateAsClient(user!.getOriginal()) + + assert.deepEqual(response, { + session: { + auth_web: user!.getId(), + }, + }) + }) +}) diff --git a/tests/session/tokens_providers/db.spec.ts b/tests/session/tokens_providers/db.spec.ts index 41f3ccd..057e5de 100644 --- a/tests/session/tokens_providers/db.spec.ts +++ b/tests/session/tokens_providers/db.spec.ts @@ -42,43 +42,6 @@ test.group('RememberMe tokens provider | DB | create', () => { password: 'secret', }) - const token = await User.rememberMeTokens.create(user) - assert.exists(token.identifier) - assert.instanceOf(token, RememberMeToken) - assert.equal(token.tokenableId, user.id) - assert.instanceOf(token.expiresAt, Date) - assert.instanceOf(token.createdAt, Date) - assert.instanceOf(token.updatedAt, Date) - assert.isDefined(token.hash) - assert.exists(token.value) - }) - - test('define token expiry at the time of generating it', async ({ assert }) => { - const db = await createDatabase() - await createTables(db) - - class User extends BaseModel { - @column({ isPrimary: true }) - declare id: number - - @column() - declare username: string - - @column() - declare email: string - - @column() - declare password: string - - static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) - } - - const user = await User.create({ - email: 'virk@adonisjs.com', - username: 'virk', - password: 'secret', - }) - const token = await User.rememberMeTokens.create(user, '20 mins') assert.exists(token.identifier) assert.instanceOf(token, RememberMeToken) @@ -116,7 +79,7 @@ test.group('RememberMe tokens provider | DB | create', () => { const user = new User() await assert.rejects( - () => User.rememberMeTokens.create(user), + () => User.rememberMeTokens.create(user, '20 mins'), 'Cannot use "User" model for managing remember me tokens. The value of column "id" is undefined or null' ) }) @@ -143,7 +106,7 @@ test.group('RememberMe tokens provider | DB | create', () => { await assert.rejects( // @ts-expect-error - () => User.rememberMeTokens.create({}), + () => User.rememberMeTokens.create({}, '20 mins'), 'Invalid user object. It must be an instance of the "User" model' ) }) @@ -176,7 +139,7 @@ test.group('RememberMe tokens provider | DB | verify', () => { password: 'secret', }) - const token = await User.rememberMeTokens.create(user) + const token = await User.rememberMeTokens.create(user, '20 mins') const freshToken = await User.rememberMeTokens.verify(new Secret(token.value!.release())) assert.instanceOf(freshToken, RememberMeToken) @@ -245,7 +208,7 @@ test.group('RememberMe tokens provider | DB | verify', () => { password: 'secret', }) - const token = await User.rememberMeTokens.create(user) + const token = await User.rememberMeTokens.create(user, '20 mins') await User.rememberMeTokens.delete(user, token.identifier) const freshToken = await User.rememberMeTokens.verify(new Secret(token.value!.release())) @@ -302,7 +265,7 @@ test.group('RememberMe tokens provider | DB | verify', () => { password: 'secret', }) - const token = await User.rememberMeTokens.create(user) + const token = await User.rememberMeTokens.create(user, '20 mins') const value = token.value!.release() const [identifier] = value.split('.') @@ -338,7 +301,7 @@ test.group('RememberMe tokens provider | DB | find', () => { password: 'secret', }) - const token = await User.rememberMeTokens.create(user) + const token = await User.rememberMeTokens.create(user, '20 mins') const freshToken = await User.rememberMeTokens.find(user, token.identifier) assert.exists(freshToken!.identifier) @@ -451,7 +414,7 @@ test.group('RememberMe tokens provider | DB | all', () => { }) await User.rememberMeTokens.create(user, '20 mins') - await User.rememberMeTokens.create(user) + await User.rememberMeTokens.create(user, '2 years') timeTravel(21 * 60) const tokens = await User.rememberMeTokens.all(user) @@ -506,7 +469,7 @@ test.group('RememberMe tokens provider | DB | recycle', () => { }) const token = await User.rememberMeTokens.create(user, '20 mins') - const freshToken = await User.rememberMeTokens.recycle(user, token.identifier) + const freshToken = await User.rememberMeTokens.recycle(user, token.identifier, '20 mins') assert.isNull(await User.rememberMeTokens.find(user, token.identifier)) assert.isNotNull(await User.rememberMeTokens.find(user, freshToken.identifier)) diff --git a/tests/session/user_providers/lucid.spec.ts b/tests/session/user_providers/lucid.spec.ts new file mode 100644 index 0000000..5b0708b --- /dev/null +++ b/tests/session/user_providers/lucid.spec.ts @@ -0,0 +1,304 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Secret } from '@adonisjs/core/helpers' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +import { createDatabase, createTables } from '../../helpers.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' +import { SessionLucidUserProvider } from '../../../modules/session_guard/user_providers/lucid.js' +import { DbRememberMeTokensProvider } from '../../../modules/session_guard/token_providers/db.js' + +test.group('Session user provider | Lucid', () => { + test('throw error when user model is not using tokens provider', async () => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + await userProvider.verifyRememberToken(new Secret('foo.bar')) + }).throws( + 'Cannot use "User" model for verifying remember me tokens. Make sure to assign a token provider to the model.' + ) +}) + +test.group('Session user provider | Lucid | findById', () => { + test('find user by id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const freshUser = await userProvider.findById(user.id) + assert.instanceOf(freshUser!.getOriginal(), User) + assert.equal(freshUser!.getId(), user.id) + }) + + test('return null when user does not exist', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + await user.delete() + + const freshUser = await userProvider.findById(user.id) + assert.isNull(freshUser) + }) +}) + +test.group('Session user provider | Lucid | createToken', () => { + test('create token for a user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await userProvider.createRememberToken(user, '20 mins') + assert.instanceOf(token, RememberMeToken) + assert.exists(token.identifier) + assert.instanceOf(token, RememberMeToken) + assert.equal(token.tokenableId, user.id) + assert.instanceOf(token.expiresAt, Date) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.exists(token.value) + }) +}) + +test.group('Session user provider | Lucid | deleteToken', () => { + test('delete existing token', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await userProvider.createRememberToken(user, '20 mins') + assert.equal(await userProvider.deleteRemeberToken(user, token.identifier), 1) + + const tokens = await User.rememberMeTokens.all(user) + assert.lengthOf(tokens, 0) + }) +}) + +test.group('Session user provider | Lucid | recycleToken', () => { + test('recycle token', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await userProvider.createRememberToken(user, '20 mins') + const freshToken = await userProvider.recycleRememberToken(user, token.identifier, '20 mins') + + assert.isNull(await User.rememberMeTokens.find(user, token.identifier)) + assert.isNotNull(await User.rememberMeTokens.find(user, freshToken.identifier)) + }) +}) + +test.group('Session user provider | Lucid | verifyToken', () => { + test('return remember token when it is valid', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await userProvider.createRememberToken(user, '20 mins') + const freshToken = await userProvider.verifyRememberToken(new Secret(token.value!.release())) + assert.instanceOf(freshToken, RememberMeToken) + assert.isUndefined(freshToken!.value) + assert.equal(freshToken!.hash, token.hash) + assert.equal(freshToken!.createdAt.getTime(), token.createdAt.getTime()) + }) +}) From 3fcc97587fd65fd2662685eaabaf608f52fa81fc Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 17:24:26 +0530 Subject: [PATCH 93/96] test: improve tests coverage --- .../user_providers/lucid.ts | 10 +- src/errors.ts | 3 +- tests/access_tokens/access_token.spec.ts | 20 +++- .../user_providers/lucid.spec.ts | 51 +++++++++ tests/auth/e_invalid_credentials.spec.ts | 12 ++ tests/auth/e_unauthorized_access.spec.ts | 108 ++++++++++++++++++ tests/session/guard/authenticate.spec.ts | 52 +++++++++ tests/session/user_providers/lucid.spec.ts | 74 ++++++++++++ 8 files changed, 320 insertions(+), 10 deletions(-) diff --git a/modules/access_tokens_guard/user_providers/lucid.ts b/modules/access_tokens_guard/user_providers/lucid.ts index c9de3f2..8448158 100644 --- a/modules/access_tokens_guard/user_providers/lucid.ts +++ b/modules/access_tokens_guard/user_providers/lucid.ts @@ -8,6 +8,7 @@ */ import { Secret } from '@adonisjs/core/helpers' +import type { LucidRow } from '@adonisjs/lucid/types/model' import { RuntimeException } from '@adonisjs/core/exceptions' import { AccessToken } from '../access_token.js' @@ -114,15 +115,8 @@ export class AccessTokensLucidUserProvider< expiresIn?: string | number } ): Promise { - const model = await this.getModel() - if (user instanceof model === false) { - throw new RuntimeException( - `Invalid user object. It must be an instance of the "${model.name}" model` - ) - } - const tokensProvider = await this.getTokensProvider() - return tokensProvider.create(user, abilities, options) + return tokensProvider.create(user as LucidRow, abilities, options) } /** diff --git a/src/errors.ts b/src/errors.ts index 9c90347..7360e03 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -98,7 +98,8 @@ export const E_UNAUTHORIZED_ACCESS = class extends Exception { * Response when access tokens driver is used */ access_tokens: (message, error, ctx) => { - switch (ctx.request.accepts(['application/vnd.api+json', 'json'])) { + switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) { + case 'html': case null: ctx.response.status(error.status).send(message) break diff --git a/tests/access_tokens/access_token.spec.ts b/tests/access_tokens/access_token.spec.ts index eafc82b..b026b5f 100644 --- a/tests/access_tokens/access_token.spec.ts +++ b/tests/access_tokens/access_token.spec.ts @@ -268,7 +268,6 @@ test.group('AccessToken token | create', () => { expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) const transientToken = AccessToken.createTransientToken(1, 40, '20 mins') - const token = new AccessToken({ identifier: '12', tokenableId: 1, @@ -282,6 +281,17 @@ test.group('AccessToken token | create', () => { prefix: 'oat_', secret: transientToken.secret, }) + const persistedToken = new AccessToken({ + identifier: '12', + tokenableId: 1, + type: 'auth_token', + name: 'my token', + hash: transientToken.hash, + createdAt, + updatedAt, + expiresAt, + lastUsedAt: null, + }) assert.deepEqual(token.toJSON(), { type: 'bearer', @@ -291,5 +301,13 @@ test.group('AccessToken token | create', () => { lastUsedAt: null, expiresAt: token.expiresAt, }) + assert.deepEqual(persistedToken.toJSON(), { + type: 'bearer', + name: 'my token', + token: undefined, + abilities: ['*'], + lastUsedAt: null, + expiresAt: token.expiresAt, + }) }) }) diff --git a/tests/access_tokens/user_providers/lucid.spec.ts b/tests/access_tokens/user_providers/lucid.spec.ts index c708257..70c4add 100644 --- a/tests/access_tokens/user_providers/lucid.spec.ts +++ b/tests/access_tokens/user_providers/lucid.spec.ts @@ -98,6 +98,57 @@ test.group('Access tokens user provider | Lucid | verify', () => { }) }) +test.group('Access tokens user provider | Lucid | createToken', () => { + test('create token for a user', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static authTokens = DbAccessTokensProvider.forModel(User) + } + + const userProvider = new AccessTokensLucidUserProvider({ + tokens: 'authTokens', + async model() { + return { + default: User, + } + }, + }) + + const user = await User.create({ + email: 'virk@adonisjs.com', + username: 'virk', + password: 'secret', + }) + + const token = await userProvider.createToken(user) + assert.exists(token.identifier) + assert.instanceOf(token, AccessToken) + assert.equal(token.tokenableId, user.id) + assert.deepEqual(token.abilities, ['*']) + assert.isNull(token.lastUsedAt) + assert.isNull(token.expiresAt) + assert.instanceOf(token.createdAt, Date) + assert.instanceOf(token.updatedAt, Date) + assert.isDefined(token.hash) + assert.equal(token.type, 'auth_token') + assert.isTrue(token.value!.release().startsWith('oat_')) + }) +}) + test.group('Access tokens user provider | Lucid | findById', () => { test('find user by id', async ({ assert }) => { const db = await createDatabase() diff --git a/tests/auth/e_invalid_credentials.spec.ts b/tests/auth/e_invalid_credentials.spec.ts index 6be2770..ab7e087 100644 --- a/tests/auth/e_invalid_credentials.spec.ts +++ b/tests/auth/e_invalid_credentials.spec.ts @@ -31,6 +31,18 @@ test.group('Errors | E_INVALID_CREDENTIALS', () => { assert.equal(ctx.response.getHeader('location'), '/') }) + test('respond with text message when session middleware is not configured', async ({ + assert, + }) => { + const error = new E_INVALID_CREDENTIALS('Invalid credentials') + + const ctx = new HttpContextFactory().create() + await error.handle(error, ctx) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), 'Invalid credentials') + }) + test('respond with json', async ({ assert }) => { const error = new E_INVALID_CREDENTIALS('Invalid credentials') diff --git a/tests/auth/e_unauthorized_access.spec.ts b/tests/auth/e_unauthorized_access.spec.ts index 7cd31ac..c759224 100644 --- a/tests/auth/e_unauthorized_access.spec.ts +++ b/tests/auth/e_unauthorized_access.spec.ts @@ -144,6 +144,114 @@ test.group('Errors | E_UNAUTHORIZED_ACCESS | session', () => { }) }) +test.group('Errors | E_UNAUTHORIZED_ACCESS | access_tokens', () => { + test('convert error to a string based response', async ({ assert }) => { + const sessionMiddleware = await new SessionMiddlewareFactory().create() + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: 'access_tokens', + }) + + const ctx = new HttpContextFactory().create() + await sessionMiddleware.handle(ctx, async () => { + return error.handle(error, ctx) + }) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), 'Unauthorized access') + }) + + test('respond with json', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: 'access_tokens', + }) + + const ctx = new HttpContextFactory().create() + + /** + * Force JSON response + */ + ctx.request.request.headers.accept = 'application/json' + await error.handle(error, ctx) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + message: 'Unauthorized access', + }, + ], + }) + }) + + test('respond with JSONAPI response', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: 'access_tokens', + }) + + const ctx = new HttpContextFactory().create() + + /** + * Force JSONAPI response + */ + ctx.request.request.headers.accept = 'application/vnd.api+json' + await error.handle(error, ctx) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + title: 'Unauthorized access', + code: 'E_UNAUTHORIZED_ACCESS', + }, + ], + }) + }) + + test('translate error message using i18n', async ({ assert }) => { + const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: 'access_tokens', + }) + const i18nManager = new I18nManagerFactory() + .merge({ + config: { + loaders: [ + () => { + return { + async load() { + return { + en: { + 'errors.E_UNAUTHORIZED_ACCESS': 'Access denied', + }, + } + }, + } + }, + ], + }, + }) + .create() + + const ctx = new HttpContextFactory().create() + await i18nManager.loadTranslations() + ctx.i18n = i18nManager.locale('en') + + /** + * Force JSON response + */ + ctx.request.request.headers.accept = 'application/json' + await error.handle(error, ctx) + + assert.isUndefined(ctx.response.getHeader('location')) + assert.deepEqual(ctx.response.getBody(), { + errors: [ + { + message: 'Access denied', + }, + ], + }) + }) +}) + test.group('Errors | E_UNAUTHORIZED_ACCESS | basic auth', () => { test('handle basic auth exception with a prompt', async ({ assert }) => { const error = new E_UNAUTHORIZED_ACCESS('Unauthorized access', { diff --git a/tests/session/guard/authenticate.spec.ts b/tests/session/guard/authenticate.spec.ts index bb82888..ee022f5 100644 --- a/tests/session/guard/authenticate.spec.ts +++ b/tests/session/guard/authenticate.spec.ts @@ -162,6 +162,40 @@ test.group('Session guard | authenticate via session', () => { assert.isFalse(guard.viaRemember) assert.isFalse(guard.attemptedViaRemember) }) + + test('mutiple calls to authenticate should be a noop', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + await assert.rejects(() => guard.authenticate(), 'Invalid or expired user session') + + /** + * Even though the session will exist from here on, the + * authenticate method will still fail, because it + * caches results from first call. + */ + const user = await userProvider.findById(1) + ctx.session.put('auth_web', user!.getId()) + + await assert.rejects(() => guard.authenticate(), 'Invalid or expired user session') + }) }) test.group('Session guard | authenticate via remember token', () => { @@ -663,6 +697,24 @@ test.group('Session guard | check', () => { assert.isFalse(guard.viaRemember) assert.isFalse(guard.attemptedViaRemember) }) + + test('rethrow errors other than E_AUTHORIZED_ACCESS', async () => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + await guard.check() + }).throws('Cannot authenticate user. Install and configure "@adonisjs/session" package') }) test.group('Session guard | authenticateAsClient', () => { diff --git a/tests/session/user_providers/lucid.spec.ts b/tests/session/user_providers/lucid.spec.ts index 5b0708b..f60051b 100644 --- a/tests/session/user_providers/lucid.spec.ts +++ b/tests/session/user_providers/lucid.spec.ts @@ -302,3 +302,77 @@ test.group('Session user provider | Lucid | verifyToken', () => { assert.equal(freshToken!.createdAt.getTime(), token.createdAt.getTime()) }) }) + +test.group('Session user provider | Lucid | createUserForGuard', () => { + test('throw error via getId when user does not have an id', async ({ assert }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + const user = await userProvider.createUserForGuard(new User()) + assert.throws( + () => user.getId(), + 'Cannot use "User" model for authentication. The value of column "id" is undefined or null' + ) + }) + + test('throw error via getId when user is not an instance of the associated model', async ({ + assert, + }) => { + const db = await createDatabase() + await createTables(db) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + + static rememberMeTokens = DbRememberMeTokensProvider.forModel(User) + } + + const userProvider = new SessionLucidUserProvider({ + async model() { + return { + default: User, + } + }, + }) + + await assert.rejects( + // @ts-expect-error + () => userProvider.createUserForGuard({}), + 'Invalid user object. It must be an instance of the "User" model' + ) + }) +}) From 0f416499aa7e98871fe445292a274bc33bc5a2c0 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 18:26:37 +0530 Subject: [PATCH 94/96] feat(session_guard): implement login and logout methods --- factories/session/main.ts | 7 +- modules/session_guard/guard.ts | 169 +++++++++++++++++++++++++--- tests/session/guard/login.spec.ts | 172 +++++++++++++++++++++++++++++ tests/session/guard/logout.spec.ts | 150 +++++++++++++++++++++++++ 4 files changed, 482 insertions(+), 16 deletions(-) create mode 100644 tests/session/guard/login.spec.ts create mode 100644 tests/session/guard/logout.spec.ts diff --git a/factories/session/main.ts b/factories/session/main.ts index dda5638..26cf20c 100644 --- a/factories/session/main.ts +++ b/factories/session/main.ts @@ -7,6 +7,10 @@ * file that was distributed with this source code. */ +import { Secret } from '@adonisjs/core/helpers' +import { setTimeout } from 'node:timers/promises' +import stringHelpers from '@adonisjs/core/helpers/string' + import { PROVIDER_REAL_USER } from '../../src/symbols.js' import { RememberMeTokenDbColumns, @@ -14,8 +18,6 @@ import { SessionWithTokensUserProviderContract, } from '../../modules/session_guard/types.js' import { RememberMeToken } from '../../modules/session_guard/remember_me_token.js' -import stringHelpers from '@adonisjs/core/helpers/string' -import { Secret } from '@adonisjs/core/helpers' /** * Representation of a fake user used to test @@ -177,6 +179,7 @@ export class SessionFakeUserWithTokensProvider expiresIn: string | number ): Promise { await this.deleteRemeberToken(user, tokenIdentifier) + await setTimeout(100) return this.createRememberToken(user, expiresIn) } } diff --git a/modules/session_guard/guard.ts b/modules/session_guard/guard.ts index af7bd44..90943cd 100644 --- a/modules/session_guard/guard.ts +++ b/modules/session_guard/guard.ts @@ -163,6 +163,8 @@ export class SessionGuard< #authenticationFailed(sessionId: string) { this.isAuthenticated = false this.viaRemember = false + this.user = undefined + this.isLoggedOut = false const error = new E_UNAUTHORIZED_ACCESS('Invalid or expired user session', { guardDriverName: this.driverName, @@ -187,10 +189,10 @@ export class SessionGuard< user: UserProvider[typeof PROVIDER_REAL_USER], rememberMeToken?: RememberMeToken ) { - this.user = user this.isAuthenticated = true - this.isLoggedOut = false this.viaRemember = !!rememberMeToken + this.user = user + this.isLoggedOut = false this.#emitter.emit('session_auth:authentication_succeeded', { ctx: this.#ctx, @@ -201,6 +203,27 @@ export class SessionGuard< }) } + /** + * Emits the login succeeded event and updates the login + * state + */ + #loginSucceeded( + sessionId: string, + user: UserProvider[typeof PROVIDER_REAL_USER], + rememberMeToken?: RememberMeToken + ) { + this.user = user + this.isLoggedOut = false + + this.#emitter.emit('session_auth:login_succeeded', { + ctx: this.#ctx, + guardName: this.#name, + sessionId, + user, + rememberMeToken, + }) + } + /** * Creates session for a given user by their user id. */ @@ -268,27 +291,30 @@ export class SessionGuard< throw this.#authenticationFailed(sessionId) } - /** - * Create session - */ - const userId = providerUser.getId() - this.#createSessionForUser(userId) - - /** - * Emit event and update local state - */ - this.#authenticationSucceeded(sessionId, providerUser.getOriginal(), token) - /** * Recycle remember token and the remember me cookie */ const recycledToken = await userProvider.recycleRememberToken( - this.user!, + providerUser.getOriginal(), token.identifier, this.#options.rememberMeTokensAge ) + + /** + * Persist remember token inside the cookie + */ this.#createRememberMeCookie(recycledToken.value!) + /** + * Create session + */ + this.#createSessionForUser(providerUser.getId()) + + /** + * Emit event and update local state + */ + this.#authenticationSucceeded(sessionId, providerUser.getOriginal(), token) + return this.user! } @@ -306,6 +332,121 @@ export class SessionGuard< return this.user } + /** + * Login user using sessions. Optionally, you can also create + * a remember me token to automatically login user when their + * session expires. + */ + async login(user: UserProvider[typeof PROVIDER_REAL_USER], remember: boolean = false) { + const session = this.#getSession() + const providerUser = await this.#userProvider.createUserForGuard(user) + + this.#emitter.emit('session_auth:login_attempted', { + ctx: this.#ctx, + user, + guardName: this.#name, + }) + + /** + * Create remember me token and persist it with the provider + * when remember me token is true. + */ + let token: RememberMeToken | undefined + if (remember) { + if (!this.#options.useRememberMeTokens) { + throw new RuntimeException('Cannot use "rememberMe" feature. It has been disabled') + } + + /** + * Here we assume the userProvider has implemented APIs to manage remember + * me tokens, since the "useRememberMeTokens" flag is enabled. + */ + const userProvider = this.#userProvider as SessionWithTokensUserProviderContract< + UserProvider[typeof PROVIDER_REAL_USER] + > + + token = await userProvider.createRememberToken( + providerUser.getOriginal(), + this.#options.rememberMeTokensAge + ) + } + + /** + * Persist remember token inside the cookie (if exists) + * Otherwise remove the cookie + */ + if (token) { + this.#createRememberMeCookie(token.value!) + } else { + this.#ctx.response.clearCookie(this.rememberMeKeyName) + } + + /** + * Create session + */ + this.#createSessionForUser(providerUser.getId()) + + /** + * Mark user as logged-in + */ + this.#loginSucceeded(session.sessionId, providerUser.getOriginal(), token) + } + + /** + * Logout a user by removing its state from the session + * store and delete the remember me cookie (if any). + */ + async logout() { + const session = this.#getSession() + const rememberMeCookie = this.#ctx.request.encryptedCookie(this.rememberMeKeyName) + + /** + * Clear client side state + */ + session.forget(this.sessionKeyName) + this.#ctx.response.clearCookie(this.rememberMeKeyName) + + /** + * Delete remember me token when + * + * - Tokens are enabled + * - A cookie exists + * - And we know about the user already + */ + if (this.user && rememberMeCookie && this.#options.useRememberMeTokens) { + /** + * Here we assume the userProvider has implemented APIs to manage remember + * me tokens, since the "useRememberMeTokens" flag is enabled. + */ + const userProvider = this.#userProvider as SessionWithTokensUserProviderContract< + UserProvider[typeof PROVIDER_REAL_USER] + > + + const token = await userProvider.verifyRememberToken(new Secret(rememberMeCookie)) + if (token) { + await userProvider.deleteRemeberToken(this.user, token.identifier) + } + } + + /** + * Update local state + */ + this.user = undefined + this.viaRemember = false + this.isAuthenticated = false + this.isLoggedOut = true + + /** + * Notify the user has been logged out + */ + this.#emitter.emit('session_auth:logged_out', { + ctx: this.#ctx, + guardName: this.#name, + user: this.user || null, + sessionId: session.sessionId, + }) + } + /** * Authenticate the current HTTP request by verifying the bearer * token or fails with an exception diff --git a/tests/session/guard/login.spec.ts b/tests/session/guard/login.spec.ts new file mode 100644 index 0000000..c95a72c --- /dev/null +++ b/tests/session/guard/login.spec.ts @@ -0,0 +1,172 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { createEmitter, pEvent, parseCookies } from '../../helpers.js' +import { SessionGuard } from '../../../modules/session_guard/guard.js' +import type { SessionGuardEvents } from '../../../modules/session_guard/types.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' +import { + SessionFakeUser, + SessionFakeUserProvider, + SessionFakeUserWithTokensProvider, +} from '../../../factories/session/main.js' + +test.group('Session guard | login', () => { + test('create session for the user', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const user = await userProvider.findById(1) + const [attempted, succeeded] = await Promise.all([ + pEvent(emitter, 'session_auth:login_attempted'), + pEvent(emitter, 'session_auth:login_succeeded'), + guard.login(user!.getOriginal()), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + expectTypeOf(succeeded!.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(succeeded!.guardName, 'web') + assert.equal(succeeded!.sessionId, ctx.session.sessionId) + + assert.deepEqual(guard.user, user!.getOriginal()) + assert.deepEqual(guard.getUserOrFail(), user!.getOriginal()) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + + assert.deepEqual(ctx.session.all(), { + auth_web: user!.getId(), + }) + }) + + test('throw error when trying to create remember me token without enabling it', async ({ + assert, + }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const user = await userProvider.findById(1) + const [attempted] = await Promise.all([ + pEvent(emitter, 'session_auth:login_attempted'), + (async () => { + await assert.rejects( + () => guard.login(user!.getOriginal(), true), + 'Cannot use "rememberMe" feature. It has been disabled' + ) + })(), + ]) + + assert.exists(attempted) + assert.isUndefined(guard.user) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + + assert.deepEqual(ctx.session.all(), {}) + }) + + test('create remember me cookie and token', async ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const user = await userProvider.findById(1) + const [attempted, succeeded] = await Promise.all([ + pEvent(emitter, 'session_auth:login_attempted'), + pEvent(emitter, 'session_auth:login_succeeded'), + guard.login(user!.getOriginal(), true), + ]) + + expectTypeOf(guard.user).toEqualTypeOf() + expectTypeOf(succeeded!.user).toEqualTypeOf() + + assert.equal(attempted!.guardName, 'web') + assert.equal(succeeded!.guardName, 'web') + assert.equal(succeeded!.sessionId, ctx.session.sessionId) + + assert.deepEqual(guard.user, user!.getOriginal()) + assert.deepEqual(guard.getUserOrFail(), user!.getOriginal()) + assert.isFalse(guard.isLoggedOut) + assert.isFalse(guard.isAuthenticated) + assert.isFalse(guard.authenticationAttempted) + assert.isFalse(guard.viaRemember) + assert.isFalse(guard.attemptedViaRemember) + + assert.deepEqual(ctx.session.all(), { + auth_web: user!.getId(), + }) + + const responseCookies = parseCookies(ctx.response.getHeader('set-cookie') as string) + assert.lengthOf(userProvider.tokens, 1) + assert.equal( + RememberMeToken.decode(responseCookies.remember_web.value)?.identifier, + userProvider.tokens[0].id + ) + }) +}) diff --git a/tests/session/guard/logout.spec.ts b/tests/session/guard/logout.spec.ts new file mode 100644 index 0000000..1761824 --- /dev/null +++ b/tests/session/guard/logout.spec.ts @@ -0,0 +1,150 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' +import { SessionMiddlewareFactory } from '@adonisjs/session/factories' + +import { SessionGuard } from '../../../modules/session_guard/guard.js' +import { SessionGuardEvents } from '../../../modules/session_guard/types.js' +import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' +import { createEmitter, defineCookies, pEvent, parseCookies, timeTravel } from '../../helpers.js' +import { + SessionFakeUser, + SessionFakeUserProvider, + SessionFakeUserWithTokensProvider, +} from '../../../factories/session/main.js' + +test.group('Session guard | logout', () => { + test('delete user session and remember me cookie', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: false, + }, + emitter, + userProvider + ) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + + const user = await userProvider.findById(1) + ctx.session.put('auth_web', user!.getId()) + + await guard.authenticate() + await guard.logout() + + assert.isUndefined(guard.user) + assert.deepEqual(ctx.session.all(), {}) + + const responseCookies = parseCookies(ctx.response.getHeader('set-cookie') as string) + assert.deepEqual(responseCookies.remember_web.expires, new Date(0)) + assert.deepEqual(responseCookies.remember_web.maxAge, -1) + }) + + test('delete remember me token using user provider', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + ctx.session.put('auth_web', user!.getId()) + + await guard.authenticate() + await guard.logout() + + assert.isUndefined(guard.user) + assert.deepEqual(ctx.session.all(), {}) + + const responseCookies = parseCookies(ctx.response.getHeader('set-cookie') as string) + assert.deepEqual(responseCookies.remember_web.expires, new Date(0)) + assert.deepEqual(responseCookies.remember_web.maxAge, -1) + + assert.lengthOf(userProvider.tokens, 0) + }) + + test('do not delete token with storage when no user was authenticated in first place', async ({ + assert, + }) => { + const ctx = new HttpContextFactory().create() + const emitter = createEmitter>() + const userProvider = new SessionFakeUserWithTokensProvider() + + const guard = new SessionGuard( + 'web', + ctx, + { + useRememberMeTokens: true, + }, + emitter, + userProvider + ) + + const user = await userProvider.findById(1) + const token = await userProvider.createRememberToken(user!.getOriginal(), '20 mins') + ctx.request.request.headers.cookie = defineCookies([ + { + key: 'remember_web', + value: token.value!.release(), + type: 'encrypted', + }, + ]) + + /** + * Setup ctx with session + */ + const sessionMiddleware = await new SessionMiddlewareFactory().create() + await sessionMiddleware.handle(ctx, async () => {}) + ctx.session.put('auth_web', user!.getId()) + + await guard.logout() + + assert.isUndefined(guard.user) + assert.deepEqual(ctx.session.all(), {}) + + const responseCookies = parseCookies(ctx.response.getHeader('set-cookie') as string) + assert.deepEqual(responseCookies.remember_web.expires, new Date(0)) + assert.deepEqual(responseCookies.remember_web.maxAge, -1) + + assert.lengthOf(userProvider.tokens, 1) + }) +}) From e9d198eae8d85234dd81ebffbee6cf2cbe38fda6 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 18:35:08 +0530 Subject: [PATCH 95/96] feat: define session guard exports --- modules/session_guard/define_config.ts | 55 ++++++++ modules/session_guard/main.ts | 14 ++ package.json | 4 +- tests/access_tokens/define_config.spec.ts | 2 +- tests/session/define_config.spec.ts | 165 ++++++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 modules/session_guard/define_config.ts create mode 100644 modules/session_guard/main.ts create mode 100644 tests/session/define_config.spec.ts diff --git a/modules/session_guard/define_config.ts b/modules/session_guard/define_config.ts new file mode 100644 index 0000000..2cf7708 --- /dev/null +++ b/modules/session_guard/define_config.ts @@ -0,0 +1,55 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import type { ConfigProvider } from '@adonisjs/core/types' + +import { SessionGuard } from './guard.js' +import type { GuardConfigProvider } from '../../src/types.js' +import { SessionLucidUserProvider } from './user_providers/lucid.js' +import type { + SessionGuardOptions, + LucidAuthenticatable, + SessionUserProviderContract, + SessionLucidUserProviderOptions, + SessionWithTokensUserProviderContract, +} from './types.js' + +/** + * Configures session tokens guard for authentication + */ +export function sessionGuard< + UseRememberTokens extends boolean, + UserProvider extends UseRememberTokens extends true + ? SessionWithTokensUserProviderContract + : SessionUserProviderContract, +>( + config: { + provider: UserProvider | ConfigProvider + } & SessionGuardOptions +): GuardConfigProvider<(ctx: HttpContext) => SessionGuard> { + return { + async resolver(name, app) { + const emitter = await app.container.make('emitter') + const provider = + 'resolver' in config.provider ? await config.provider.resolver(app) : config.provider + return (ctx) => new SessionGuard(name, ctx, config, emitter as any, provider) + }, + } +} + +/** + * Configures user provider that uses Lucid models to authenticate + * users using sessions + */ +export function sessionUserProvider( + config: SessionLucidUserProviderOptions +): SessionLucidUserProvider { + return new SessionLucidUserProvider(config) +} diff --git a/modules/session_guard/main.ts b/modules/session_guard/main.ts new file mode 100644 index 0000000..2837a41 --- /dev/null +++ b/modules/session_guard/main.ts @@ -0,0 +1,14 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export { RememberMeToken } from './remember_me_token.js' +export { SessionGuard } from './guard.js' +export { DbRememberMeTokensProvider } from './token_providers/db.js' +export { sessionGuard, sessionUserProvider } from './define_config.js' +export { SessionLucidUserProvider } from './user_providers/lucid.js' diff --git a/package.json b/package.json index 08331c1..c8c223d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "./services/main": "./build/services/auth.js", "./initialize_auth_middleware": "./build/src/middleware/initialize_auth_middleware.js", "./access_tokens": "./build/modules/access_tokens_guard/main.js", - "./types/access_tokens": "./build/modules/access_tokens_guard/types.js" + "./types/access_tokens": "./build/modules/access_tokens_guard/types.js", + "./session": "./build/modules/session_guard/main.js", + "./types/session": "./build/modules/session_guard/types.js" }, "scripts": { "pretest": "npm run lint", diff --git a/tests/access_tokens/define_config.spec.ts b/tests/access_tokens/define_config.spec.ts index 7d5486d..408b5b6 100644 --- a/tests/access_tokens/define_config.spec.ts +++ b/tests/access_tokens/define_config.spec.ts @@ -8,6 +8,7 @@ */ import { test } from '@japa/runner' +import { configProvider } from '@adonisjs/core' import { BaseModel, column } from '@adonisjs/lucid/orm' import { AppFactory } from '@adonisjs/core/factories/app' import type { ApplicationService } from '@adonisjs/core/types' @@ -21,7 +22,6 @@ import { DbAccessTokensProvider, AccessTokensLucidUserProvider, } from '../../modules/access_tokens_guard/main.js' -import { configProvider } from '@adonisjs/core' test.group('defineConfig', () => { test('configure lucid user provider', ({ assert, expectTypeOf }) => { diff --git a/tests/session/define_config.spec.ts b/tests/session/define_config.spec.ts new file mode 100644 index 0000000..6189c3d --- /dev/null +++ b/tests/session/define_config.spec.ts @@ -0,0 +1,165 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { configProvider } from '@adonisjs/core' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService } from '@adonisjs/core/types' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { createEmitter } from '../helpers.js' +import { SessionGuard, SessionLucidUserProvider } from '../../modules/session_guard/main.js' +import { sessionGuard, sessionUserProvider } from '../../modules/session_guard/define_config.js' + +test.group('defineConfig', () => { + test('configure lucid user provider', ({ assert, expectTypeOf }) => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const userProvider = sessionUserProvider({ + async model() { + return { + default: User, + } + }, + }) + assert.instanceOf(userProvider, SessionLucidUserProvider) + expectTypeOf(userProvider).toEqualTypeOf>() + }) + + test('configure session guard', async ({ assert, expectTypeOf }) => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const ctx = new HttpContextFactory().create() + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + app.container.bind('emitter', () => createEmitter()) + + const guard = await sessionGuard({ + useRememberMeTokens: false, + provider: sessionUserProvider({ + async model() { + return { + default: User, + } + }, + }), + }).resolver('api', app) + + assert.instanceOf(guard(ctx), SessionGuard) + expectTypeOf(guard).returns.toEqualTypeOf< + SessionGuard> + >() + }) + + test('configure session guard and enable remember me tokens', async ({ + assert, + expectTypeOf, + }) => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const ctx = new HttpContextFactory().create() + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + app.container.bind('emitter', () => createEmitter()) + + const guard = await sessionGuard({ + useRememberMeTokens: true, + provider: sessionUserProvider({ + async model() { + return { + default: User, + } + }, + }), + }).resolver('api', app) + + assert.instanceOf(guard(ctx), SessionGuard) + expectTypeOf(guard).returns.toEqualTypeOf< + SessionGuard> + >() + }) + + test('register user provider from a config provider', async ({ assert, expectTypeOf }) => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column() + declare email: string + + @column() + declare password: string + } + + const ctx = new HttpContextFactory().create() + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + app.container.bind('emitter', () => createEmitter()) + + const userProvider = configProvider.create(async () => { + return sessionUserProvider({ + async model() { + return { + default: User, + } + }, + }) + }) + + const guard = await sessionGuard({ + useRememberMeTokens: false, + provider: userProvider, + }).resolver('api', app) + + assert.instanceOf(guard(ctx), SessionGuard) + expectTypeOf(guard).returns.toEqualTypeOf< + SessionGuard> + >() + }) +}) From 3ca7beb2953edaa48eebc2ed792dc36f77f1b8f4 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 18 Jan 2024 18:39:33 +0530 Subject: [PATCH 96/96] chore: create bundle using tsup --- package.json | 51 ++++++++++++++++++++---------- tests/session/guard/logout.spec.ts | 3 +- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index c8c223d..8ae8f56 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,10 @@ "type": "module", "main": "build/index.js", "files": [ - "build/configure.js", - "build/configure.d.ts", - "build/index.js", - "build/index.d.ts", - "build/factories", - "build/providers", - "build/services", - "build/src", - "build/stubs" + "build", + "!build/bin", + "!build/tests", + "!build/factories" ], "engines": { "node": ">=18.16.0" @@ -34,18 +29,20 @@ "scripts": { "pretest": "npm run lint", "test": "c8 npm run quick:test", - "quick:test": "cross-env NODE_DEBUG=\"adonisjs:auth:*\" node --enable-source-maps --loader=ts-node/esm ./bin/test.js", "clean": "del-cli build", - "copy:templates": "copyfiles \"stubs/**/**/*.stub\" build", - "compile": "npm run lint && npm run clean && tsc && npm run copy:templates", + "typecheck": "tsc --noEmit", + "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", + "precompile": "npm run lint && npm run clean", + "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", + "postcompile": "npm run copy:templates", "build": "npm run compile", - "release": "np", - "version": "npm run build", "prepublishOnly": "npm run build", "lint": "eslint . --ext=.ts", - "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "release": "np", + "version": "npm run build", "sync-labels": "github-label-sync --labels .github/labels.json adonisjs/auth", - "format": "prettier --write ." + "quick:test": "cross-env NODE_DEBUG=\"adonisjs:auth:*\" node --enable-source-maps --loader=ts-node/esm ./bin/test.js" }, "keywords": [ "adonisjs", @@ -105,6 +102,7 @@ "sqlite3": "^5.1.7", "timekeeper": "^2.3.1", "ts-node": "^10.9.2", + "tsup": "^8.0.1", "typescript": "^5.3.3" }, "prettier": "@adonisjs/prettier-config", @@ -165,5 +163,26 @@ "@japa/plugin-adonisjs": { "optional": true } + }, + "tsup": { + "entry": [ + "./index.ts", + "./src/types.ts", + "./providers/auth_provider.ts", + "./src/plugins/japa/api_client.ts", + "./src/plugins/japa/browser_client.ts", + "./services/auth.ts", + "./src/middleware/initialize_auth_middleware.ts", + "./modules/access_tokens_guard/main.ts", + "./modules/access_tokens_guard/types.ts", + "./modules/session_guard/main.ts", + "./modules/session_guard/types.ts" + ], + "outDir": "./build", + "clean": true, + "format": "esm", + "dts": false, + "sourcemap": true, + "target": "esnext" } } diff --git a/tests/session/guard/logout.spec.ts b/tests/session/guard/logout.spec.ts index 1761824..fc4d521 100644 --- a/tests/session/guard/logout.spec.ts +++ b/tests/session/guard/logout.spec.ts @@ -13,8 +13,7 @@ import { SessionMiddlewareFactory } from '@adonisjs/session/factories' import { SessionGuard } from '../../../modules/session_guard/guard.js' import { SessionGuardEvents } from '../../../modules/session_guard/types.js' -import { RememberMeToken } from '../../../modules/session_guard/remember_me_token.js' -import { createEmitter, defineCookies, pEvent, parseCookies, timeTravel } from '../../helpers.js' +import { createEmitter, defineCookies, parseCookies } from '../../helpers.js' import { SessionFakeUser, SessionFakeUserProvider,