├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── docs ├── auth0 │ ├── Makefile │ ├── README.md │ ├── deploy │ │ └── template.json │ ├── integration │ │ ├── fetchUserProfile.js │ │ ├── fetchUserProfile.spec.js │ │ ├── installation_guide.md │ │ └── recipe.json │ └── media │ │ ├── 256x256-logo.png │ │ ├── 460x260-column-1.png │ │ ├── 460x260-column-2.png │ │ ├── 460x260-column-3.png │ │ ├── DID example.jpg │ │ ├── Dock Web3 ID Auth0 Integration.jpg │ │ ├── app-store.svg │ │ └── gplay.svg └── oauth2_setup.md ├── dprint.json ├── jest.config.js ├── next.config.js ├── package.json ├── pages └── api │ ├── index.js │ ├── oauth2 │ ├── authorize.js │ ├── token.js │ └── userinfo.js │ ├── register.js │ └── verify.js ├── public ├── DID example.jpg ├── Dock Web3 ID Auth0 Integration.jpg ├── app-store.svg ├── globals.css ├── gplay.svg ├── refresh.js └── wallet-icon.svg ├── src ├── config.js ├── oauth │ ├── model.js │ └── server.js ├── utils │ ├── client-crypto.js │ ├── request-validation.js │ ├── sanitize.js │ ├── valid-url.js │ └── verify-credential.js └── views │ ├── base.js │ ├── error.js │ └── scan-qr.js ├── tests ├── __mocks__ │ ├── axios.js │ └── memjs.js ├── integration │ └── pages │ │ ├── authorize.test.js │ │ ├── fixtures.js │ │ ├── helpers.js │ │ ├── index.test.js │ │ ├── register.test.js │ │ ├── token.test.js │ │ ├── userinfo.test.js │ │ └── verify.test.js ├── setup.js └── unit │ ├── oauth │ └── model.test.js │ ├── placeholder.test.js │ ├── utils │ ├── client-crypto.test.js │ ├── request-validation.test.js │ ├── sanitize.test.js │ ├── valid-url.test.js │ └── verify-credential.test.js │ └── views │ └── error.test.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest"], 3 | "globals": { 4 | "process": "readonly" 5 | }, 6 | "env": { 7 | "browser": true, 8 | "es6": true 9 | }, 10 | "extends": ["next", "eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended", "airbnb-base"], 11 | "rules": { 12 | "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }], 13 | "class-methods-use-this": "off", 14 | "no-console": "off", 15 | "no-nested-ternary": "off", 16 | "no-param-reassign": "off", 17 | "no-useless-escape": "off", 18 | "prefer-destructuring": "off", 19 | "camelcase": "off", 20 | "max-len": "off", 21 | "no-underscore-dangle": "off", 22 | "react/display-name": "off", 23 | "react/prop-types": 0, 24 | "operator-linebreak": "off", 25 | "object-curly-newline": "off", 26 | "comma-dangle": "off", 27 | "indent": "off", 28 | "implicit-arrow-linebreak": "off", 29 | "function-paren-newline": "off", 30 | "no-confusing-arrow": "off" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | run-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | 12 | - name: Setup node 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: '16.x' 16 | 17 | - name: Install 18 | run: | 19 | yarn install --frozen-lockfile 20 | 21 | - name: Format check 22 | run: yarn format-check 23 | 24 | - name: Lint 25 | run: yarn lint 26 | 27 | - name: Run tests 28 | run: yarn coverage 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dock Web3 ID 2 | 3 | Web3 ID is a blockchain-based Authentication and Authorization system that uses Decentralized Identifiers (DIDs) and Verifiable Credentials. There is an existing live service hosted at https://auth.dock.io however you may clone this repository/deploy it to vercel to spin up your own instance. It is mostly stateless, but does rely on a memcached instance currently for short-lived data transfer. No long term storage of user data is used, tokens and codes are obtained through cryptography. You can use this service to allow your users to provide their own user data, like you would request from "Login with Facebook" or "Sign in with Github". 4 | 5 | Note: You will still want to verify a users email if requested with this service. 6 | 7 | Features: 8 | - No long term storage of user/client data 9 | - User provides and controls their own data 10 | - Uses `did:dock` and `did:key` DIDs 11 | - Cryptographic client id/secrets 12 | - OAuth 2.0 spec compliant 13 | 14 | Roadmap: 15 | - Support requesting specific credentials/data 16 | - Decouple verification from the Dock API 17 | - Look into supporting the SIOP spec 18 | - OpenID Connect implementation 19 | - Support more DID types 20 | - Support non self-signed credentials 21 | - Zero Knowledge Proofs 22 | 23 | For more information about the upcoming features, [get in touch](https://www.dock.io/contact). 24 | 25 | ## As an OAuth 2.0 provider 26 | 27 | This service can be used directly as an OAuth 2.0 provider with your favourite OAuth library. See the documentation for [OAuth 2.0 setup](docs/oauth2_setup.md). You are welcome to use our hosted version or your own. Setup instructions are for the hosted vesion, simply replace with your own domain to configure for another endpoint. 28 | 29 | ## Under the Hood 30 | 31 | Decentralized Identifiers (DIDs) are cryptographically verifiable pseudonymous identifiers created by the user, owned by the user, and independent of any organization. DIDs contain no personal data about the user, the user may provide extra data you request such as their name, email etc. An example of a DID stored on the Dock blockchain could look like this: 32 | 33 | ![sample-did](./public/DID%20example.jpg) 34 | 35 | Each DID is supported by a Public-Private cryptographic key pair. 36 | 37 | When a user scans the QR Code generated by the Dock Web3 ID service they are prompted to provide their data as requested by the scopes. The user’s Private Key associated with the DID digitally signs a Verifiable Credential. This Verifiable Credential with that data contains a cryptographic hash that ensures that it wasn’t modified since it was created and signed - and most importantly verifies that the user who owns that DID is providing that data. 38 | 39 | This Authentication Verifiable Credential is sent to this auth service, which verifies that the credential was indeed cryptographically signed by the correct user and grants the user access to the application. Your server can then request the user data using the access token provided through the standard OAuth 2.0/Auth0 flow. 40 | 41 | ## Development 42 | 43 | First, setup [the environment variables](#env-vars) and pre-requisite services and then you can run the development server: 44 | 45 | ```bash 46 | npm run dev 47 | # or 48 | yarn dev 49 | ``` 50 | 51 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 52 | 53 | ## Building and Deployment 54 | 55 | Building the application for production can be done with: 56 | 57 | ```bash 58 | npm run build 59 | # or 60 | yarn build 61 | ``` 62 | 63 | or you may wish to run it as a custom server with: 64 | 65 | ```bash 66 | npm run start 67 | # or 68 | yarn start 69 | ``` 70 | 71 | ## Env Vars 72 | 73 | Running the auth server requires: 74 | 75 | - A free [Truvera API key](https://truvera.io/) in order to verify credentials. Set through API_KEY 76 | - A memcached instance, you can find many free ones online for a small project or use a local docker container. Set through MEMCACHIER_SERVERS 77 | - A secure, randomly generated cryptographic key for authorizing clients set through CRYPTO_KEY 78 | - A public domain set through SERVER_URL (defaults to localhost:3000) 79 | 80 | Example `.env.local` file: 81 | ``` 82 | API_KEY=certs-api-key 83 | MEMCACHIER_SERVERS=your-memcached-uri:11211 84 | CRYPTO_KEY=32charactersecurecryptokey 85 | SERVER_URL=https://mydomain.com/ 86 | ``` 87 | 88 | ## Vercel Deploy 89 | 90 | Deploy to vercel in one click with this button 91 | 92 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fdocknetwork%2Fauth-server&env=MEMCACHIER_SERVERS,API_KEY,CRYPTO_KEY&envDescription=Environment%20variables%20needed%20for%20this%20applicaton&envLink=https%3A%2F%2Fgithub.com%2Fdocknetwork%2Fauth-server%23env-vars&project-name=did-auth&repo-name=did-auth&redirect-url=https%3A%2F%2Fdock.io%2F%3Fgtm_source%3Dauthdeploy) 93 | 94 | -------------------------------------------------------------------------------- /docs/auth0/Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | docker run --rm -t -v `pwd`/integration/:/data/integration/ auth0josh/auth0-integration-testing npm run integration:lint 3 | 4 | test: 5 | docker run --rm -t -v `pwd`/integration/:/data/integration/ auth0josh/auth0-integration-testing npm run test:social-connection 6 | 7 | zip: 8 | zip -r integration-social-connection.zip integration media 9 | 10 | deploy_init: 11 | docker run --rm -it -v `pwd`/integration/:/data/integration/ -v `pwd`/deploy:/data/deploy auth0josh/auth0-integration-testing bash deploy-scripts/init.sh 12 | 13 | deploy_get_token: 14 | docker run --rm -t -v `pwd`/integration/:/data/integration/ -v `pwd`/deploy:/data/deploy auth0josh/auth0-integration-testing bash deploy-scripts/get-token.sh 15 | 16 | deploy_create: 17 | docker run --rm -t -v `pwd`/integration/:/data/integration/ -v `pwd`/deploy:/data/deploy auth0josh/auth0-integration-testing bash deploy-scripts/social-connection-create.sh 18 | 19 | deploy_get: 20 | docker run --rm -t -v `pwd`/integration/:/data/integration/ -v `pwd`/deploy:/data/deploy auth0josh/auth0-integration-testing bash deploy-scripts/social-connection-get.sh 21 | 22 | deploy_get_all: 23 | docker run --rm -t -v `pwd`/integration/:/data/integration/ -v `pwd`/deploy:/data/deploy auth0josh/auth0-integration-testing bash deploy-scripts/social-connection-get-all.sh 24 | 25 | deploy_update: 26 | docker run --rm -t -v `pwd`/integration/:/data/integration/ -v `pwd`/deploy:/data/deploy auth0josh/auth0-integration-testing bash deploy-scripts/social-connection-update.sh 27 | 28 | deploy_delete: 29 | docker run --rm -t -v `pwd`/integration/:/data/integration/ -v `pwd`/deploy:/data/deploy auth0josh/auth0-integration-testing bash deploy-scripts/social-connection-delete.sh -------------------------------------------------------------------------------- /docs/auth0/README.md: -------------------------------------------------------------------------------- 1 | # Social Connection 2 | 3 | This template is used to create Social Connections integrations. 4 | 5 | ## Documentation 6 | 7 | - [Social Connection integration documentation](https://auth0.com/docs/customize/integrations/marketplace-partners/social-connections-for-partners) 8 | - [Custom OAuth2 Connection documentation](https://auth0.com/docs/authenticate/identity-providers/social-identity-providers/oauth2s) 9 | 10 | ## Getting started 11 | 12 | This repo contains all the files required to create an integration that our mutual customers can install. In the `integration` folder you'll find the following files: 13 | 14 | ### `recipe.json` 15 | 16 | This file defines the template for the connection settings in Auth0. The following properties must be present: 17 | 18 | * `authorizationURL` - this is the URL that Auth0 will use to redirect users. If this user needs to be dynamic, use `{{configuration_name}}` and a corresponding field `name` in the `configuration` property explained below. 19 | * `tokenURL` - this is the URL that Auth0 will use to exchange the authorization code returned after login. This can also be dynamic, as explained above. 20 | * `permissions` - this must contain an array of permission objects that the tenant admin can select when configuring this connection. Use the following properties to describe the permission(s): 21 | * `scope` - the value of the permission sent to the authorization URL via a `scope` parameter 22 | * `label` - the human-readable label for this permission shown to tenant admins 23 | * `required` - boolean to indicate whether this permission is always sent 24 | * `default` - boolean to indicate whether this checkbox should be checked by default (should be `true` if required) 25 | * `configuration` - this must contain an array of form field objects that the tenant can use to configure their connection. This array must include a field with a name of `client_id` and `client_secret` to contain the credentials used for exchanging an authorization code. Use the following properties to describe the fields: 26 | * `name` - form field name used in the connection options object. If your authorization and/or token URL(s) are dynamic, this should match the value contained within the brackets. 27 | * `label` - the human-readable label for this field shown to tenant admins 28 | * `description` - description text shown beneath the field 29 | * `required` - whether the field is required or not 30 | 31 | ### `fetchUserProfile.js` 32 | 33 | This is the JavaScript that will run once an access token is returned. There are 2 `TODO` items here: 34 | 35 | * Add the userinfo URL that should be called with the returned access token. Make sure that the authorization used against this endpoint is correct. 36 | * Add the logic to map the returned identity to the [Auth0 user profile](https://auth0.com/docs/manage-users/user-accounts/user-profiles/user-profile-structure#user-profile-attributes). 37 | 38 | ### `fetchUserProfile.spec.js` 39 | 40 | This is the Jest unit test suite that will run against your completed profile script. Adjust the value here to account for changes made in the script. 41 | 42 | ### `installation_guide.md` 43 | 44 | This is the Markdown-formatted instructions that tenant admins will use to install and configure your connection. This file has a number of `TODO` items that indicate what needs to be added. Your guide should retain the same format and general Auth0 installation steps. 45 | 46 | ## Build and test your Social Connection 47 | 48 | We've included a few helpful scripts in a `Makefile` that should help you build, test, and submit a quality integration. The commands below require Docker to be installed and running on your local machine (though no direct Docker experience is necessary). Download and install Docker [using these steps for your operating system](https://docs.docker.com/get-docker/). 49 | 50 | * `make test` - this will run the spec file explained above, along with a few other integrity checks. 51 | * `make lint` - this will check and format your JS code according to our recommendations. 52 | * `make deploy_init` - use this command to initialize deployments to a test tenant. You will need to [create a machine-to-machine application](https://auth0.com/docs/get-started/auth0-overview/create-applications/machine-to-machine-apps) authorized for the Management API with permissions `read:connections`, `update:connections`, `delete:connections`, and `create:connections`. 53 | * `make deploy_get_token` - use this command after `deploy_init` to generate an access token 54 | * `make deploy_create` - use this command to create a new connection based on the current integration files. If this successfully completes, you will see a URL in your terminal that will allow you to enable applications and try the connection. 55 | * `make deploy_update` - use this command to update the created connection based on the current integration files. 56 | * `make deploy_delete` - use this command to destoy the connection. 57 | 58 | ## Submit for review 59 | 60 | When your integration has been written and tested, it's time to submit it for review. 61 | 62 | 1. Replace the `media/256x256-logo.png` file with an image of the same size and format (256 pixel square on a transparent background) 63 | 1. If you provided value-proposition columns and would like to include images, replace the `media/460x260-column-*.png` files with images of the same size and format; otherwise, delete these images before submitting 64 | 1. Run `make zip` in the root of the integration package and upload the resulting archive to the Jira ticket. 65 | 66 | If you have any questions or problems with this, please reply back on the support ticket! 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /docs/auth0/deploy/template.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "icon_url": "https://identicons.dev/static/icons/mono/png/icon-integrations-marketplace.png" 4 | }, 5 | "strategy": "oauth2", 6 | "name": "sign-in-with-dock-web3-id", 7 | "display_name": "Sign in with Dock Web3 ID - Integration Test" 8 | } -------------------------------------------------------------------------------- /docs/auth0/integration/fetchUserProfile.js: -------------------------------------------------------------------------------- 1 | /* globals request */ 2 | module.exports = function fetchUserProfile(accessToken, context, callback) { 3 | request.get( 4 | { 5 | url: "https://auth.dock.io/oauth2/userinfo", 6 | headers: { 7 | Authorization: `Bearer ${accessToken}`, 8 | }, 9 | }, 10 | (err, resp, body) => { 11 | if (err) { 12 | return callback(err); 13 | } 14 | 15 | if (resp.statusCode !== 200) { 16 | return callback(new Error(body)); 17 | } 18 | 19 | let bodyParsed; 20 | try { 21 | bodyParsed = JSON.parse(body); 22 | } catch (jsonError) { 23 | return callback(new Error(body)); 24 | } 25 | 26 | const profile = { 27 | user_id: `${bodyParsed.id || bodyParsed.sub}`, 28 | ...bodyParsed, 29 | }; 30 | 31 | return callback(null, profile); 32 | } 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /docs/auth0/integration/fetchUserProfile.spec.js: -------------------------------------------------------------------------------- 1 | const fetchUserProfile = require("./fetchUserProfile"); 2 | 3 | const defaultContext = {}; 4 | 5 | describe("fetchUserProfile", () => { 6 | afterEach(() => { 7 | jest.clearAllMocks(); 8 | }); 9 | 10 | describe("request.get options", () => { 11 | beforeEach(() => { 12 | global.request = { get: jest.fn() }; 13 | fetchUserProfile("__test_access_token__", defaultContext, jest.fn()); 14 | }); 15 | 16 | it("should call request.get", () => { 17 | expect(global.request.get).toHaveBeenCalledTimes(1); 18 | }); 19 | 20 | it("should get the correct endpoint", () => { 21 | expect(global.request.get.mock.calls[0][0].url).toEqual( 22 | "https://auth.dock.io/oauth2/userinfo" 23 | ); 24 | }); 25 | 26 | it("should use the passed-in access token", () => { 27 | expect(global.request.get.mock.calls[0][0].headers).toMatchObject({ 28 | Authorization: "Bearer __test_access_token__", 29 | }); 30 | }); 31 | }); 32 | 33 | describe("request.get callback", () => { 34 | afterEach(() => { 35 | global.request = {}; 36 | }); 37 | 38 | const profileCallback = jest.fn(); 39 | 40 | it("should call the callback with an error", () => { 41 | const requestError = new Error("__test_error__"); 42 | global.request = { get: jest.fn((opts, cb) => cb(requestError)) }; 43 | fetchUserProfile(1, defaultContext, profileCallback); 44 | 45 | expect(profileCallback.mock.calls).toHaveLength(1); 46 | expect(profileCallback.mock.calls[0][0]).toEqual(requestError); 47 | }); 48 | 49 | it("should call the callback with the response body if request is not successful", () => { 50 | global.request = { 51 | get: jest.fn((opts, cb) => { 52 | cb(null, { statusCode: 401 }, "__test_body__"); 53 | }), 54 | }; 55 | fetchUserProfile(1, defaultContext, profileCallback); 56 | 57 | expect(profileCallback.mock.calls).toHaveLength(1); 58 | expect(profileCallback.mock.calls[0][0]).toEqual(new Error("__test_body__")); 59 | }); 60 | 61 | it("should handle invalid JSON responses", () => { 62 | global.request = { 63 | get: jest.fn((opts, cb) => { 64 | cb(null, { statusCode: 200 }, "__test_invalid_json__"); 65 | }), 66 | }; 67 | fetchUserProfile(1, defaultContext, profileCallback); 68 | 69 | expect(profileCallback.mock.calls).toHaveLength(1); 70 | expect(profileCallback.mock.calls[0][0]).toEqual(new Error("__test_invalid_json__")); 71 | }); 72 | 73 | it("should call the callback with the profile if response is ok", () => { 74 | const responseBody = { 75 | sub: "__test_sub__", 76 | field1: "test data", 77 | }; 78 | 79 | global.request = { 80 | get: jest.fn((opts, cb) => { 81 | cb(null, { statusCode: 200 }, JSON.stringify(responseBody)); 82 | }), 83 | }; 84 | 85 | fetchUserProfile(1, defaultContext, profileCallback); 86 | 87 | expect(profileCallback.mock.calls).toHaveLength(1); 88 | expect(profileCallback.mock.calls[0][0]).toBeNull(); 89 | expect(profileCallback.mock.calls[0][1]).toEqual({ 90 | user_id: "__test_sub__", 91 | ...responseBody, 92 | }); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /docs/auth0/integration/installation_guide.md: -------------------------------------------------------------------------------- 1 | # Sign in with Dock Web3 ID - Auth0 Integration 2 | 3 | The Dock Web3 ID integration allows users to privately sign in to your app using [Decentralized Identifiers](https://dock.io/decentralized-identifiers) (DIDs) & [Verifiable Credentials](https://dock.io/verifiable-credentials) (VCs) via scanning QR codes with their Dock Wallet apps. This step-by-step guide will show you how to integrate Dock Web3 ID into your authentication flow easily. 4 | 5 | ## Under the Hood 6 | 7 | Decentralized Identifiers (DIDs) are cryptographically verifiable pseudonymous identifiers created by the user, owned by the user, and independent of any organization. When a user creates an account in the Dock Wallet app, a DID will automatically be created for them. 8 | 9 | DIDs contain no personal data about the user. An example of a DID stored on the Dock blockchain could look like this: 10 | 11 | ![sample-did](../media/DID%20example.jpg) 12 | 13 | Each DID is supported by a Public-Private cryptographic key pair. 14 | 15 | When a user scans the QR Code generated by the Dock Web3 ID Auth0 integration, the user’s Private Key associated with the DID digitally signs a Verifiable Credential. This Verifiable Credential contains a cryptographic hash that ensures that it wasn’t modified since it was created and signed. 16 | 17 | This Authentication Verifiable Credential is sent to Dock, who verifies that the Credential was indeed cryptographically signed by the correct user and grants the user access to the app. 18 | 19 | ![auth-flow](../media/Dock%20Web3%20ID%20Auth0%20Integration.jpg) 20 | 21 | Coming soon, users will be able to create multiple DIDs and update their keys in the Dock Wallet app. 22 | 23 | ## Prerequisites 24 | 25 | 1. An Auth0 account and tenant. [Sign up for free here](https://auth0.com/signup). 26 | 27 | ## Set up Login with Dock Web3 ID 28 | 29 | Register for a Client ID and Client Secret by posting a REST request like the following to the Dock auth server . 30 | 31 | ```bash 32 | curl -X POST -H "Content-Type: application/json" https://auth.dock.io/register -d '{"name": "My App", "website": "https://www.my-app.org", "redirect_uris":["https://YOUR_AUTH0_DOMAIN/login/callback"]}' 33 | ``` 34 | 35 | NOTE: You can get your Auth0 domain from the **Settings** tab on the **Application** page 36 | 37 | You will get back a response similar to the following: 38 | 39 | ```json 40 | { 41 | "client_id": "jT4iswsxJsoHLbMXjKECcdGeXMaGowc6IIB/YRYspJqkuYEAynhUNQUOVMosGxwjJ5/DKNMafsmupXiA26GfceUIorCIlQDo+f7iq/H7MFtkfDBkKnW1iUEOcC/9nP2E", 42 | "client_secret": "8z+zGijpdnR33bON+8IOQKXdX2Eg6rn0mwksis0dz22fv5UMToGbjazcGNRM1Ary" 43 | } 44 | ``` 45 | 46 | ## Add the Connection 47 | 48 | 1. Use the **Add Integration** button above to start. 49 | 2. Read the necessary access requirements and click `Continue`. 50 | 3. Configure the integration with the `Client ID` and `Client Secret` you received in the [Login with Dock Registration](#setup-login-with-dock-web3-id) steps. 51 | 4. Select the **Permissions** needed for your applications 52 | 5. Turn off **Sync user profile attributes at each login** 53 | 6. Click `Create` 54 | 7. On the **Applications** tab choose the application you setup in the [Auth0 Registration](#auth0-registration) steps to enable "Sign in with DockID" for that application. 55 | 56 | ## Test connection 57 | 58 | You're ready to [test this Connection](https://auth0.com/docs/authenticate/identity-providers/test-connections). 59 | 60 | ## Get the Truvera Wallet 61 | 62 | [![Google Play](../media/gplay.svg)](https://play.google.com/store/apps/details?id=com.truvera.app) [![App Store](../media/app-store.svg)](https://apps.apple.com/br/app/truvera-wallet/id6739359697) 63 | -------------------------------------------------------------------------------- /docs/auth0/integration/recipe.json: -------------------------------------------------------------------------------- 1 | { 2 | "authorizationURL": "https://auth.dock.io/oauth2/authorize", 3 | "tokenURL": "https://auth.dock.io/oauth2/token", 4 | "permissions": [ 5 | { 6 | "scope": "public email", 7 | "label": "Email", 8 | "required": false, 9 | "default": true 10 | } 11 | ], 12 | "configuration": [ 13 | { 14 | "name": "client_id", 15 | "label": "Client ID", 16 | "description": "Public unique identifier for this application.", 17 | "required": true 18 | }, 19 | { 20 | "name": "client_secret", 21 | "label": "Client Secret", 22 | "description": "Secret for this application.", 23 | "required": true 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /docs/auth0/media/256x256-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/docs/auth0/media/256x256-logo.png -------------------------------------------------------------------------------- /docs/auth0/media/460x260-column-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/docs/auth0/media/460x260-column-1.png -------------------------------------------------------------------------------- /docs/auth0/media/460x260-column-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/docs/auth0/media/460x260-column-2.png -------------------------------------------------------------------------------- /docs/auth0/media/460x260-column-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/docs/auth0/media/460x260-column-3.png -------------------------------------------------------------------------------- /docs/auth0/media/DID example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/docs/auth0/media/DID example.jpg -------------------------------------------------------------------------------- /docs/auth0/media/Dock Web3 ID Auth0 Integration.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/docs/auth0/media/Dock Web3 ID Auth0 Integration.jpg -------------------------------------------------------------------------------- /docs/auth0/media/app-store.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/auth0/media/gplay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/oauth2_setup.md: -------------------------------------------------------------------------------- 1 | # Dock Web3 ID as an OAuth 2.0 provider 2 | 3 | The primary feature of Dock Web3 ID is to be an authentication provider which can accept DIDs and Verfiable Credentials. To achieve this, we support oauth2. Typically, you would use an oauth2 library and that would have setup instructions for different providers. Please note that the domain here is for Dock's hosted instance. If you wish to use your own, replace the domain appropriately. 4 | 5 | ## Client registration 6 | 7 | Register for a Client ID and Client Secret by posting a POST REST request like the following to the auth server https://auth.dock.io/register. 8 | 9 | ```bash 10 | curl -X POST -H "Content-Type: application/json" https://auth.dock.io/register -d '{"name": "My App", "website": "https://www.my-app.org", "redirect_uris":["https://YOUR_DOMAIN/your_callback_uri"]}' 11 | ``` 12 | 13 | You will get back a response similar to the following: 14 | 15 | ```json 16 | { 17 | "client_id":"jT4iswsxJsoHLbMXjKECcdGeXMaGowc6IIB/YRYspJqkuYEAynhUNQUOVMosGxwjJ5/DKNMafsmupXiA26GfceUIorCIlQDo+f7iq/H7MFtkfDBkKnW1iUEOcC/9nP2E", 18 | "client_secret":"8z+zGijpdnR33bON+8IOQKXdX2Eg6rn0mwksis0dz22fv5UMToGbjazcGNRM1Ary" 19 | } 20 | ``` 21 | 22 | ## Grant type & scopes 23 | 24 | The service currently only supports the `authorization_code` grant type and response type `code`. 25 | 26 | Ultimately the scope support depends on the wallet application being used to gather and send data, but the typical scopes that are supported are: 27 | - public/profile 28 | - email 29 | 30 | ## Endpoints 31 | 32 | The following endpoints are exposed by the auth service for OAuth 2.0: 33 | 34 | | URL | Purpose | 35 | | ------------ | ------------ | 36 | | https://auth.dock.io/oauth2/authorize | Authorize | 37 | | https://auth.dock.io/oauth2/token | Access token | 38 | | https://auth.dock.io/oauth2/userinfo | Get profile/user info | 39 | 40 | 41 | ## NextAuth.js provider 42 | 43 | It is pretty simple to integrate with NextAuth.js (or similar JS auth provider) with the below snippet: 44 | 45 | ```javascript 46 | function DockAuthProvider(options, domain = 'auth.dock.io') { 47 | return { 48 | id: 'dockauth', 49 | name: 'Web3 ID', 50 | type: 'oauth', 51 | version: '2.0', 52 | scope: 'public email', 53 | params: { grant_type: 'authorization_code' }, 54 | accessTokenUrl: `https://${domain}/oauth2/token`, 55 | authorizationUrl: `https://${domain}/oauth2/authorize?response_type=code`, 56 | profileUrl: `https://${domain}/oauth2/userinfo`, 57 | profile(profile) { 58 | return { 59 | id: profile.id, 60 | name: profile.name || profile.login, 61 | email: profile.email, 62 | image: profile.image, 63 | }; 64 | }, 65 | ...options, 66 | }; 67 | } 68 | 69 | DockAuthProvider({ 70 | clientId: process.env.DOCK_CLIENT_ID, 71 | clientSecret: process.env.DOCK_SECRET, 72 | }); 73 | ``` -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "includes": ["src/**/*.js", "pages/**/*.js", "cypress/**/*.js", "tests/**/*.js"], 3 | "excludes": [ 4 | "src/temp-templates/**/*.js" 5 | ], 6 | "plugins": [ 7 | "https://plugins.dprint.dev/prettier-0.7.0.json@4e846f43b32981258cef5095b3d732522947592e090ef52333801f9d6e8adb33" 8 | ], 9 | "prettier": { 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": false, 12 | "jsxSingleQuote": false, 13 | "printWidth": 100, 14 | "proseWrap": "always", 15 | "quoteProps": "as-needed", 16 | "semi": true, 17 | "singleQuote": true, 18 | "tabWidth": 2, 19 | "trailingComma": "es5", 20 | "useTabs": false, 21 | "bracketSameLine": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest'); 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: './', 6 | }); 7 | 8 | // Add any custom config to be passed to Jest 9 | const customJestConfig = { 10 | moduleNameMapper: { 11 | "setupFilesAfterEnv": ["/tests/setup.js"], 12 | 13 | // Handle module aliases (this will be automatically configured for you soon) 14 | '^@/components/(.*)$': '/components/$1', 15 | 16 | '^@/pages/(.*)$': '/pages/$1', 17 | }, 18 | testEnvironment: 'jest-environment-jsdom', 19 | }; 20 | 21 | const jestConfig = async () => { 22 | const nextJestConfig = await createJestConfig(customJestConfig)(); 23 | return { 24 | ...nextJestConfig, 25 | coverageThreshold: { 26 | global: { 27 | branches: 100, 28 | functions: 100, 29 | lines: 100, 30 | statements: 100, 31 | } 32 | }, 33 | transformIgnorePatterns: [ 34 | "/node_modules/(?!@polkadot|@babel|@docknetwork)" 35 | ], 36 | globals: { 37 | Uint8Array: Uint8Array, 38 | ArrayBuffer: ArrayBuffer 39 | }, 40 | }; 41 | }; 42 | 43 | module.exports = jestConfig; 44 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | eslint: { 4 | dirs: ['pages', 'src', 'tests'], 5 | }, 6 | async rewrites() { 7 | return [ 8 | { 9 | source: '/oauth2/:path*', 10 | destination: '/api/oauth2/:path*', 11 | }, 12 | { 13 | source: '/oauth/:path*', 14 | destination: '/api/oauth/:path*', 15 | }, 16 | { 17 | source: '/userinfo', 18 | destination: '/api/oauth2/userinfo', 19 | }, 20 | { 21 | source: '/authorize', 22 | destination: '/api/oauth2/authorize', 23 | }, 24 | { 25 | source: '/register', 26 | destination: '/api/register', 27 | }, 28 | { 29 | source: '/verify', 30 | destination: '/api/verify', 31 | }, 32 | { 33 | source: '/:path*', 34 | destination: '/api' 35 | }, 36 | ] 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-api", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "dev:test": "SKIP_AUTH_FOR_TESTS='true' next dev", 8 | "start": "NODE_ENV='production' next start", 9 | "export": "next export", 10 | "lint": "next lint", 11 | "test": "jest --verbose ./tests", 12 | "test:integration": "jest --verbose ./tests/integration", 13 | "test:unit": "jest --verbose ./tests/unit", 14 | "start:e2e": "npx start-server-and-test dev:test 3000 \"yarn test:e2e\"", 15 | "format": "dprint fmt && yarn lint --fix", 16 | "format-check": "dprint check", 17 | "coverage": "jest --coverage --collectCoverageFrom={pages,src}/**/**" 18 | }, 19 | "dependencies": { 20 | "@node-oauth/oauth2-server": "^4.1.1", 21 | "axios": "^0.24.0", 22 | "memjs": "^1.3.0", 23 | "next": "12.1.0", 24 | "node-mocks-http": "^1.11.0", 25 | "qrcode": "^1.5.0", 26 | "react": "17.0.2", 27 | "react-dom": "17.0.2" 28 | }, 29 | "devDependencies": { 30 | "@babel/preset-env": "^7.16.11", 31 | "babel-jest": "^27.5.1", 32 | "dprint": "^0.27.1", 33 | "eslint": "7.32.0", 34 | "eslint-config-airbnb-base": "^15.0.0", 35 | "eslint-config-next": "12.0.3", 36 | "eslint-plugin-jest": "^23.8.2", 37 | "jest": "^27.5.1", 38 | "jest-mock-axios": "^4.5.0", 39 | "regenerator-runtime": "^0.13.9", 40 | "start-server-and-test": "^1.14.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pages/api/index.js: -------------------------------------------------------------------------------- 1 | export default async (req, res) => { 2 | res.json({ 3 | status: 'good', 4 | }); 5 | }; 6 | 7 | export const config = { 8 | api: { 9 | bodyParser: { 10 | sizeLimit: '500kb', 11 | responseLimit: '500kb', 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /pages/api/oauth2/authorize.js: -------------------------------------------------------------------------------- 1 | import OAuth2Server from '@node-oauth/oauth2-server'; 2 | import oauth, { model } from '../../../src/oauth/server'; 3 | import { SERVER_URL, WALLET_APP_URI } from '../../../src/config'; 4 | import getPageHTML from '../../../src/views/scan-qr'; 5 | import getErrorHTML from '../../../src/views/error'; 6 | import isValidAuthRequest from '../../../src/utils/request-validation'; 7 | 8 | import { decodeClientID } from '../../../src/utils/client-crypto'; 9 | 10 | function throwError(res, expectsHTML, errorMsg, url) { 11 | if (expectsHTML) { 12 | res.send(getErrorHTML(errorMsg, url)); 13 | } else { 14 | res.status(400).send(errorMsg); 15 | } 16 | } 17 | 18 | export default async (req, res) => { 19 | const request = new OAuth2Server.Request(req); 20 | const response = new OAuth2Server.Response(res); 21 | const acceptHeader = req.headers && req.headers.accept; 22 | const expectsHTML = acceptHeader && acceptHeader.indexOf('application/json') === -1; 23 | const clientId = req.query.client_id && req.query.client_id.replace(' ', '+'); 24 | const scope = req.query.scope; 25 | const clientInfo = decodeClientID(clientId); 26 | 27 | if (!clientInfo) { 28 | return throwError(res, expectsHTML, 'Invalid client ID', req.query.redirect_uri); 29 | } 30 | 31 | if (clientInfo.redirectUri !== req.query.redirect_uri) { 32 | return throwError(res, expectsHTML, 'Invalid redirect URI', clientInfo.redirectUri); 33 | } 34 | 35 | // Ensure the request query is valid, otherwise show json/html error state 36 | if (!isValidAuthRequest(req)) { 37 | return throwError(res, expectsHTML, 'Not a valid auth request', clientInfo.redirectUri); 38 | } 39 | 40 | const vcSubmitId = clientId.substr(0, 8) + req.query.state; 41 | const currentVCCheck = await model.getVCCheck(vcSubmitId); 42 | if (currentVCCheck && currentVCCheck.user) { 43 | request.query.access_token = currentVCCheck.id; 44 | await oauth.authorize(request, response); 45 | const redirectTo = response.headers.location; 46 | if (expectsHTML) { 47 | res.redirect(redirectTo); 48 | } else { 49 | res.json({ redirect: redirectTo }); 50 | } 51 | } else { 52 | if (!currentVCCheck) { 53 | await model.insertVCCheck(vcSubmitId, req.query.state); 54 | } 55 | 56 | const submitUrl = `${SERVER_URL}/verify?id=${vcSubmitId}&scope=${scope}&client_name=${encodeURIComponent( 57 | clientInfo.name 58 | )}&client_website=${encodeURIComponent(clientInfo.website)}`; 59 | if (expectsHTML) { 60 | const deepLinkWrappedUrl = WALLET_APP_URI + encodeURIComponent(submitUrl); 61 | const html = await getPageHTML(req.query, deepLinkWrappedUrl, clientInfo); 62 | res.send(html); 63 | } else { 64 | res.json({ submitUrl }); 65 | } 66 | } 67 | 68 | return true; 69 | }; 70 | 71 | export const config = { 72 | api: { 73 | bodyParser: { 74 | sizeLimit: '800kb', 75 | }, 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /pages/api/oauth2/token.js: -------------------------------------------------------------------------------- 1 | import OAuth2Server from '@node-oauth/oauth2-server'; 2 | import oauth from '../../../src/oauth/server'; 3 | 4 | export default async (req, res) => { 5 | const request = new OAuth2Server.Request(req); 6 | const response = new OAuth2Server.Response(res); 7 | try { 8 | await oauth.token(request, response); 9 | res.json(response.body); 10 | } catch (e) { 11 | console.error(e); 12 | res.status(400).send(e.message); 13 | } 14 | }; 15 | 16 | export const config = { 17 | api: { 18 | bodyParser: { 19 | sizeLimit: '1mb', 20 | responseLimit: '1mb', 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /pages/api/oauth2/userinfo.js: -------------------------------------------------------------------------------- 1 | import OAuth2Server from '@node-oauth/oauth2-server'; 2 | import oauth from '../../../src/oauth/server'; 3 | 4 | export default async (req, res) => { 5 | const request = new OAuth2Server.Request(req); 6 | const response = new OAuth2Server.Response(res); 7 | try { 8 | const result = await oauth.authenticate(request, response); 9 | res.json(result.user); 10 | } catch (e) { 11 | console.error(e); 12 | res.status(400).send(e.message); 13 | } 14 | }; 15 | 16 | export const config = { 17 | api: { 18 | bodyParser: { 19 | sizeLimit: '1mb', 20 | responseLimit: '1mb', 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /pages/api/register.js: -------------------------------------------------------------------------------- 1 | import { encodeClientId, createClientSecret } from '../../src/utils/client-crypto'; 2 | import isValidHttpUrl from '../../src/utils/valid-url'; 3 | 4 | export default async (req, res) => { 5 | if (req.method !== 'POST') { 6 | res.status(400).json({ 7 | error: 'Unsupported method', 8 | }); 9 | return; 10 | } 11 | 12 | const { name, redirect_uris, website } = req.body; 13 | 14 | // Ensure required parameters exist 15 | if (!redirect_uris || !name || !website || !Array.isArray(redirect_uris)) { 16 | res 17 | .status(400) 18 | .send('Parameters are required: redirect_uris (array of urls), name (string), website (url)'); 19 | return; 20 | } 21 | 22 | if (redirect_uris.length > 1) { 23 | res.status(400).send('Only one redirect_uri is supported at the moment'); 24 | return; 25 | } 26 | 27 | if (!isValidHttpUrl(website)) { 28 | res.status(400).send('Website must be a valid HTTP/HTTPS URL'); 29 | return; 30 | } 31 | 32 | if (!isValidHttpUrl(redirect_uris[0])) { 33 | res.status(400).send('redirect_uri must be a valid HTTP/HTTPS URL'); 34 | return; 35 | } 36 | 37 | const clientId = encodeClientId(req.body); 38 | const clientSecret = createClientSecret(clientId, redirect_uris); 39 | res.json({ 40 | client_id: clientId, 41 | client_secret: clientSecret, 42 | }); 43 | }; 44 | 45 | export const config = { 46 | api: { 47 | bodyParser: { 48 | sizeLimit: '4mb', 49 | responseLimit: '1mb', 50 | }, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /pages/api/verify.js: -------------------------------------------------------------------------------- 1 | import { model } from '../../src/oauth/server'; 2 | import { verifyCredential } from '../../src/utils/verify-credential'; 3 | 4 | export default async (req, res) => { 5 | // Ensure required parameters exist 6 | const { vc } = req.body; 7 | const id = req.query.id && req.query.id.replace(' ', '+'); 8 | if (req.method !== 'POST' || !vc || !id) { 9 | const error = 'Missing or invalid post body'; 10 | console.error(error); 11 | res.status(400).json({ 12 | error, 13 | }); 14 | return; 15 | } 16 | 17 | // Get the check, error if it doesnt exist 18 | const vcCheck = await model.getVCCheck(id); 19 | if (!vcCheck) { 20 | const error = `Invalid authorization ID, please go back and try again. (ID: ${id})`; 21 | console.error(error); 22 | res.status(400).json({ 23 | error, 24 | }); 25 | return; 26 | } 27 | 28 | // Check was completed previously, return valid 29 | if (vcCheck.complete) { 30 | res.json({ 31 | verified: true, 32 | userId: vcCheck.user.id, 33 | }); 34 | return; 35 | } 36 | 37 | try { 38 | const userId = typeof vc.issuer === 'object' ? vc.issuer.id : vc.issuer; 39 | const [isVerified, verifyError] = await verifyCredential(id, vc); 40 | if (isVerified) { 41 | // now that we are verified, we need to update the model so that 42 | // when user calls check it will return acess token 43 | await model.completeVCCheck(id, { 44 | ...vc.credentialSubject, 45 | id: userId, 46 | user_id: userId, 47 | state: undefined, 48 | }); 49 | } 50 | 51 | res.json({ 52 | verified: isVerified, 53 | error: verifyError, 54 | userId, 55 | }); 56 | } catch (e) { 57 | console.error(e); 58 | res.status(400).json({ 59 | verified: false, 60 | error: e.message, 61 | }); 62 | } 63 | }; 64 | 65 | export const config = { 66 | api: { 67 | bodyParser: { 68 | sizeLimit: '4mb', 69 | responseLimit: '500kb', 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /public/DID example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/public/DID example.jpg -------------------------------------------------------------------------------- /public/Dock Web3 ID Auth0 Integration.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docknetwork/auth-server/51874d730f9692f2878496b56b0882d8783856db/public/Dock Web3 ID Auth0 Integration.jpg -------------------------------------------------------------------------------- /public/app-store.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | background: #000000; 6 | color: #ffffff; 7 | } 8 | 9 | html { 10 | -webkit-print-color-adjust: exact; 11 | } 12 | 13 | a { 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | -webkit-print-color-adjust: exact; 21 | } 22 | 23 | *, 24 | *::before, 25 | *::after { 26 | box-sizing: border-box; 27 | font-family: Montserrat; 28 | } 29 | 30 | h2, 31 | h1 { 32 | font-family: Montserrat; 33 | margin: 0; 34 | } 35 | 36 | p, 37 | body { 38 | font-family: 'Nunito Sans', sans-serif; 39 | } 40 | 41 | p { 42 | margin: 20px 0; 43 | color: #D4D4D8; 44 | font-size: 16px; 45 | line-height: 24px; 46 | } 47 | 48 | .content-wrapper { 49 | display: flex; 50 | flex-direction: column; 51 | justify-content: center; 52 | align-items: center; 53 | width: 100%; 54 | min-height: 100%; 55 | background-color: rgba(0,0,0,.05); 56 | padding: 20px; 57 | min-height: 100vh; 58 | } 59 | 60 | .redirecting-wrapper, 61 | .qr-wrapper { 62 | margin-bottom: 24px; 63 | } 64 | 65 | .redirecting-wrapper, 66 | .qr-wrapper img { 67 | background: #ffffff; 68 | width: 252px; 69 | height: 251px; 70 | border: 3px solid #D4D4D8; 71 | border-radius: 12px; 72 | margin-top: 16px; 73 | } 74 | 75 | .redirecting-wrapper { 76 | display: flex; 77 | flex-direction: column; 78 | padding: 44px 20px 20px 20px; 79 | } 80 | 81 | .redirecting-spinner-wrapper { 82 | width: 44px; 83 | height: 44px; 84 | margin: 0 auto; 85 | } 86 | 87 | .redirecting-wrapper > p { 88 | font-style: normal; 89 | font-weight: 400; 90 | font-size: 16px; 91 | line-height: 24px; 92 | text-align: center; 93 | color: #3F3F46; 94 | margin-top: 24px; 95 | } 96 | 97 | .redirecting-wrapper > p a { 98 | color: #0063F7; 99 | text-decoration: underline; 100 | } 101 | 102 | .main > h1 { 103 | margin: 0; 104 | } 105 | 106 | .submit-btn { 107 | border: none; 108 | align-items: center; 109 | appearance: none; 110 | color: #ffffff; 111 | font-weight: 600; 112 | font-family: 'Nunito Sans', sans-serif; 113 | cursor: pointer; 114 | font-size: 16px; 115 | line-height: 24px; 116 | background: #0063F7; 117 | border-radius: 99px; 118 | display: flex; 119 | flex-direction: row; 120 | justify-content: center; 121 | align-items: center; 122 | padding: 13px 25px; 123 | min-width: 236px; 124 | height: 50px; 125 | margin-bottom: 56px; 126 | } 127 | 128 | .main { 129 | display: flex; 130 | flex-direction: column; 131 | justify-content: center; 132 | align-items: center; 133 | text-align: center; 134 | width: 100%; 135 | max-width: 624px; 136 | } 137 | 138 | .get-wallet-prompt { 139 | display: flex; 140 | flex-direction: row; 141 | justify-content: center; 142 | align-items: flex-start; 143 | width: 100%; 144 | background: #27272A; 145 | margin-bottom: 40px; 146 | padding: 20px; 147 | gap: 16px; 148 | max-width: 415.5px; 149 | min-height: 118px; 150 | left: 508px; 151 | top: 612px; 152 | border-radius: 16px; 153 | } 154 | 155 | .get-wallet-prompt-content > p { 156 | margin: 4px 0 20px 0; 157 | } 158 | 159 | .get-wallet-buttons { 160 | width: 100%; 161 | display: flex; 162 | margin-top: 10px; 163 | } 164 | 165 | .get-wallet-buttons > a:last-child { 166 | margin-left: auto; 167 | } 168 | 169 | .get-wallet-prompt-content { 170 | width: 100%; 171 | text-align: left; 172 | } 173 | 174 | .get-wallet-prompt-content h2 { 175 | color: #ffffff; 176 | } 177 | 178 | .get-wallet-prompt-logo { 179 | flex-shrink: 0; 180 | margin-right: auto; 181 | } 182 | 183 | .lds-ring { 184 | display: inline-block; 185 | position: relative; 186 | width: 44px; 187 | height: 44px; 188 | } 189 | 190 | .lds-ring div { 191 | box-sizing: border-box; 192 | display: block; 193 | position: absolute; 194 | width: 44px; 195 | height: 44px; 196 | margin: 4px; 197 | border: 4px solid #0063F7; 198 | border-radius: 50%; 199 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 200 | border-color: #0063F7 transparent transparent transparent; 201 | } 202 | 203 | .lds-ring div:nth-child(1) { 204 | animation-delay: -0.45s; 205 | } 206 | 207 | .lds-ring div:nth-child(2) { 208 | animation-delay: -0.3s; 209 | } 210 | 211 | .lds-ring div:nth-child(3) { 212 | animation-delay: -0.15s; 213 | } 214 | 215 | @keyframes lds-ring { 216 | 0% { 217 | transform: rotate(0deg); 218 | } 219 | 100% { 220 | transform: rotate(360deg); 221 | } 222 | } 223 | 224 | @media only screen and (max-width: 500px) { 225 | .get-wallet-buttons { 226 | flex-direction: column; 227 | } 228 | 229 | .get-wallet-prompt { 230 | flex-direction: column; 231 | } 232 | 233 | .get-wallet-prompt-logo { 234 | margin: 0 auto; 235 | } 236 | 237 | .get-wallet-prompt-content { 238 | text-align: center; 239 | } 240 | 241 | .get-wallet-buttons > a, 242 | .get-wallet-buttons > a:last-child { 243 | margin-left: auto; 244 | margin-right: auto; 245 | } 246 | 247 | .get-wallet-buttons > a:last-child { 248 | margin-top: 4px; 249 | } 250 | 251 | .main { 252 | margin-top: 30px; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /public/gplay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /public/refresh.js: -------------------------------------------------------------------------------- 1 | const intervalCheck = setInterval(checkStatus, 2500); 2 | 3 | function onComplete(url) { 4 | document.getElementById('qr-wrapper').style.display = 'none'; 5 | document.getElementById('redirecting').style.display = 'flex'; 6 | document.getElementById('redirect-uri-link').href = url; 7 | window.location.href = url; 8 | } 9 | 10 | function checkStatus() { 11 | axios.get(window.location.href, { 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | maxRedirects: 0, 16 | }) 17 | .then(result => { 18 | if (result.status === 200) { 19 | if (result.data && result.data.redirect) { 20 | onComplete(result.data.redirect); 21 | clearInterval(intervalCheck); 22 | } 23 | } 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /public/wallet-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export const DATA_TTL = 60 * 60 * 12; // 12 hours 2 | export const TOKEN_TTL = 60 * 60; // 1 hour 3 | export const WALLET_APP_URI = 'dockwallet://didauth?url='; 4 | export const APP_STORE_URI = 'https://apps.apple.com/br/app/truvera-wallet/id6739359697'; 5 | export const GPLAY_STORE_URI = 'https://play.google.com/store/apps/details?id=com.truvera.app'; 6 | 7 | export const API_KEY = process.env.API_KEY; 8 | export const API_KEY_TESTNET = process.env.API_KEY_TESTNET; 9 | 10 | export const DOCK_API_VERIFY_URL = 11 | process.env.DOCK_API_VERIFY_URL || 'https://api.truvera.io/verify'; 12 | export const DOCK_API_VERIFY_URL_TESTNET = 13 | process.env.DOCK_API_VERIFY_URL_TESTNET || 'https://api-testnet.truvera.io/verify'; 14 | 15 | export const SERVER_URL = 16 | process.env.SERVER_URL || process.env.VERCEL_URL || 'http://localhost:3000'; 17 | -------------------------------------------------------------------------------- /src/oauth/model.js: -------------------------------------------------------------------------------- 1 | import { Client } from 'memjs'; 2 | import { DATA_TTL, TOKEN_TTL } from '../config'; 3 | import { decodeClientID, isValidClientSecret } from '../utils/client-crypto'; 4 | 5 | function getKey(id) { 6 | return `dockauthprefix:${id}`; 7 | } 8 | 9 | export default class MemcachedOAuthModel { 10 | constructor() { 11 | this.client = Client.create(); 12 | } 13 | 14 | async getAccessToken(bearerToken) { 15 | const vcCheckToken = await this.getVCCheck(bearerToken); 16 | 17 | if (vcCheckToken && vcCheckToken.user) { 18 | const currentUnixTime = Math.floor(Date.now() / 1000); 19 | return { 20 | accessTokenExpiresAt: new Date((currentUnixTime + TOKEN_TTL) * 1000), 21 | user: vcCheckToken.user, 22 | }; 23 | } 24 | 25 | const accessToken = await this.get('token', bearerToken); 26 | if (accessToken) { 27 | accessToken.refreshTokenExpiresAt = new Date(accessToken.refreshTokenExpiresAt); 28 | accessToken.accessTokenExpiresAt = new Date(accessToken.accessTokenExpiresAt); 29 | return accessToken; 30 | } 31 | 32 | return false; 33 | } 34 | 35 | async getClient(clientIdUnformed, clientSecretUnformed) { 36 | const clientId = decodeURIComponent(clientIdUnformed).replace(' ', '+'); 37 | const clientSecret = clientSecretUnformed 38 | ? decodeURIComponent(clientSecretUnformed).replace(' ', '+') 39 | : null; 40 | const clientInfo = decodeClientID(clientId); 41 | 42 | if (!clientInfo) { 43 | console.warn('Cannot find client info for ID:', clientId); 44 | return false; 45 | } 46 | 47 | if (clientSecret && !isValidClientSecret(clientId, clientSecret)) { 48 | console.warn('Invalid client secret provided'); 49 | return false; 50 | } 51 | 52 | return { 53 | clientId, 54 | clientSecret, 55 | redirectUris: [clientInfo.redirectUri], 56 | grants: ['authorization_code'], 57 | }; 58 | } 59 | 60 | async saveToken(token, client, user) { 61 | const savedToken = { 62 | accessToken: token.accessToken, 63 | accessTokenExpiresAt: token.accessTokenExpiresAt, 64 | clientId: client.clientId, 65 | refreshToken: token.refreshToken, 66 | refreshTokenExpiresAt: token.refreshTokenExpiresAt, 67 | userId: user.id, 68 | client, 69 | user, 70 | }; 71 | 72 | await this.set('token', token.accessToken, savedToken); 73 | return savedToken; 74 | } 75 | 76 | async getAuthorizationCode(code) { 77 | const res = await this.get('authCode', code); 78 | if (res) { 79 | res.expiresAt = new Date(res.expiresAt); 80 | return res; 81 | } 82 | return false; 83 | } 84 | 85 | async saveAuthorizationCode(code, client, user) { 86 | await this.set('authCode', code.authorizationCode, { 87 | ...code, 88 | client, 89 | user, 90 | }); 91 | 92 | return code; 93 | } 94 | 95 | async revokeAuthorizationCode(code) { 96 | await this.delete('authCode', code.authorizationCode); 97 | return code; 98 | } 99 | 100 | async delete(type, id) { 101 | await this.client.delete(getKey(`${type}:${id}`)); 102 | } 103 | 104 | async set(type, id, value) { 105 | await this.client.set(getKey(`${type}:${id}`), value ? JSON.stringify(value) : null, { 106 | expires: DATA_TTL, 107 | }); 108 | return value; 109 | } 110 | 111 | async get(type, id) { 112 | const result = await this.client.get(getKey(`${type}:${id}`)); 113 | return result && result.value && JSON.parse(result.value); 114 | } 115 | 116 | async insertVCCheck(vcId, state) { 117 | const check = { 118 | id: vcId, 119 | state, 120 | }; 121 | await this.set('vccheck', vcId, check); 122 | return check; 123 | } 124 | 125 | async completeVCCheck(id, user) { 126 | const res = await this.get('vccheck', id); 127 | if (res) { 128 | res.user = user; 129 | res.complete = true; 130 | await this.set('vccheck', id, res); 131 | return true; 132 | } 133 | 134 | return false; 135 | } 136 | 137 | async getVCCheck(id) { 138 | const res = await this.get('vccheck', id); 139 | return res; 140 | } 141 | 142 | async getRefreshToken() { 143 | // Return false here as this auth solution doesnt use refresh tokens 144 | return false; 145 | } 146 | 147 | async getUser() { 148 | // Return false as user/password login isnt used here 149 | return false; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/oauth/server.js: -------------------------------------------------------------------------------- 1 | import OAuth2Server from '@node-oauth/oauth2-server'; 2 | import { TOKEN_TTL } from '../config'; 3 | import MemcachedOAuthModel from './model'; 4 | 5 | // Using globals here for nextjs dev mode reloads 6 | if (!global.authModel) { 7 | global.authModel = new MemcachedOAuthModel(); 8 | } 9 | 10 | export const model = global.authModel; 11 | 12 | if (!global.oauthServer) { 13 | global.oauthServer = new OAuth2Server({ 14 | model: global.authModel, 15 | allowBearerTokensInQueryString: true, 16 | accessTokenLifetime: TOKEN_TTL, 17 | refreshTokenLifetime: TOKEN_TTL, 18 | allowExtendedTokenAttributes: false, 19 | }); 20 | } 21 | 22 | export default global.oauthServer; 23 | -------------------------------------------------------------------------------- /src/utils/client-crypto.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export const ENCODING_DELIMITER = '\n'; 4 | export const CLIENT_ID_PREFIX = 'da:'; 5 | export const CLIENT_SECRET_PREFIX = 'das:'; 6 | 7 | const CRYPTO_ALGORITHM = 'aes-256-cbc'; 8 | const CRYPTO_CIPHER_OUTPUT = 'base64'; 9 | const CRYPTO_NONCE_LENGTH = 4; 10 | const CRYPTO_KEY = process.env.CRYPTO_KEY || '6352e481f4338d176352e481f4338d17'; 11 | 12 | if (!process.env.CRYPTO_KEY) { 13 | console.warn( 14 | 'WARNING: Using a compromised hard-coded static test/development key, set the CRYPTO_KEY env variable' 15 | ); 16 | } 17 | 18 | export function cleanInput(input) { 19 | return input.replaceAll(ENCODING_DELIMITER, '').trim(); 20 | } 21 | 22 | export function encodeClientId({ name, website, redirect_uris }) { 23 | const nonce = crypto.randomBytes(CRYPTO_NONCE_LENGTH).toString('hex'); 24 | const toEncodeClientID = `${CLIENT_ID_PREFIX}${nonce}${cleanInput( 25 | website 26 | )}${ENCODING_DELIMITER}${cleanInput(name)}${ENCODING_DELIMITER}${cleanInput(redirect_uris[0])}`; 27 | const encryptedId = encrypt(toEncodeClientID, CRYPTO_KEY); 28 | return encryptedId; 29 | } 30 | 31 | export function getHash(str) { 32 | return crypto.createHash('md5').update(str).digest('hex'); 33 | } 34 | 35 | export function createClientSecret(clientId) { 36 | const clientIDHash = getHash(clientId, CRYPTO_KEY.length); 37 | const nonce = crypto.randomBytes(CRYPTO_NONCE_LENGTH).toString('hex'); 38 | const toEncodeClientSecret = `${CLIENT_SECRET_PREFIX}${nonce}${clientIDHash}`; 39 | return encrypt(toEncodeClientSecret, clientIDHash); 40 | } 41 | 42 | export function isValidClientSecret(clientId, clientSecret) { 43 | const clientIDHash = getHash(clientId); 44 | const decryptedSecret = decrypt(clientSecret, clientIDHash); 45 | 46 | if (decryptedSecret.length < 16) { 47 | return false; 48 | } 49 | 50 | if (decryptedSecret.substr(0, CLIENT_SECRET_PREFIX.length) !== CLIENT_SECRET_PREFIX) { 51 | return false; 52 | } 53 | 54 | const secretHash = decryptedSecret.substr(CLIENT_SECRET_PREFIX.length + CRYPTO_NONCE_LENGTH * 2); 55 | return secretHash === clientIDHash; 56 | } 57 | 58 | export function decodeClientID(clientId) { 59 | if (!clientId) { 60 | return null; 61 | } 62 | 63 | const unpackedStr = decrypt(clientId, CRYPTO_KEY); 64 | if (unpackedStr.length < 8) { 65 | return null; 66 | } 67 | 68 | if (unpackedStr.substr(0, CLIENT_ID_PREFIX.length) !== CLIENT_ID_PREFIX) { 69 | return null; 70 | } 71 | 72 | const splitStr = unpackedStr 73 | .substr(ENCODING_DELIMITER.length + CRYPTO_NONCE_LENGTH * 2 + 2) 74 | .split(ENCODING_DELIMITER); 75 | const website = splitStr[0]; 76 | const name = splitStr[1]; 77 | const redirectUri = splitStr[2]; 78 | 79 | if (!website || !name || !redirectUri) { 80 | return null; 81 | } 82 | 83 | return { 84 | website: website.trim(), 85 | name: name.trim(), 86 | redirectUri: redirectUri.trim(), 87 | }; 88 | } 89 | 90 | export function encrypt(text, iv) { 91 | const cipher = crypto.createCipheriv( 92 | CRYPTO_ALGORITHM, 93 | CRYPTO_KEY, 94 | iv.padEnd(16, 'f').substr(0, 16) 95 | ); 96 | let encrypted = cipher.update(text, 'utf-8', CRYPTO_CIPHER_OUTPUT); 97 | encrypted += cipher.final(CRYPTO_CIPHER_OUTPUT); 98 | return encrypted; 99 | } 100 | 101 | export function decrypt(encryptedText, iv) { 102 | try { 103 | const decipher = crypto.createDecipheriv( 104 | CRYPTO_ALGORITHM, 105 | CRYPTO_KEY, 106 | iv.padEnd(16, 'f').substr(0, 16) 107 | ); 108 | let decrypted = decipher.update(encryptedText, 'base64'); 109 | decrypted = Buffer.concat([decrypted, decipher.final()]); 110 | return decrypted.toString(); 111 | } catch (e) { 112 | console.error(e); 113 | return ''; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/utils/request-validation.js: -------------------------------------------------------------------------------- 1 | export default function isValidAuthRequest(req) { 2 | if (!req.query) { 3 | return false; 4 | } 5 | 6 | const { state, client_id, response_type, redirect_uri } = req.query; 7 | return !!( 8 | typeof state === 'string' && 9 | state && 10 | typeof client_id === 'string' && 11 | client_id && 12 | typeof response_type === 'string' && 13 | response_type === 'code' && // Only allow code response type currently 14 | typeof redirect_uri === 'string' && 15 | redirect_uri 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/sanitize.js: -------------------------------------------------------------------------------- 1 | export default function sanitize(str) { 2 | if (!str || typeof str !== 'string') { 3 | return ''; 4 | } 5 | return str 6 | .replace(/&/g, '&') 7 | .replace(//g, '>') 9 | .replace(/"/g, '"') 10 | .replace(/'/g, '''); 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/valid-url.js: -------------------------------------------------------------------------------- 1 | export default function isValidHttpUrl(string) { 2 | let url; 3 | 4 | try { 5 | url = new URL(string); 6 | } catch (_) { 7 | return false; 8 | } 9 | 10 | return url.protocol === 'http:' || url.protocol === 'https:'; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/verify-credential.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { DOCK_API_VERIFY_URL, API_KEY } from '../config'; 3 | 4 | export async function postVerify(credential) { 5 | try { 6 | const d = await axios.post(DOCK_API_VERIFY_URL, credential, { 7 | headers: { 8 | 'DOCK-API-TOKEN': API_KEY, 9 | }, 10 | }); 11 | return [d.data.verified, !d.data.verified ? d.data : null]; 12 | } catch (e) { 13 | console.error(e); 14 | return [false, e]; 15 | } 16 | } 17 | 18 | export function ensureAuthCredential(id, credential) { 19 | if (!credential.type || credential.type.indexOf('DockAuthCredential') === -1) { 20 | throw new Error('Wrong credential type'); 21 | } 22 | 23 | const subject = credential.credentialSubject; 24 | if (Array.isArray(subject)) { 25 | throw new Error('Subject cannot be array'); 26 | } 27 | 28 | if (typeof subject !== 'object') { 29 | throw new Error('Subject must be object'); 30 | } 31 | 32 | if (!subject.state) { 33 | throw new Error('Subject requires state'); 34 | } 35 | 36 | const subjectState = subject.state.replace(' ', '+'); 37 | if (!process.env.DISABLE_STATE_CHECK && subjectState !== id) { 38 | throw new Error(`State mismatch, subject: ${subjectState} - id: ${id}`); 39 | } 40 | } 41 | 42 | export async function verifyCredential(id, credential) { 43 | ensureAuthCredential(id, credential); 44 | const isVerified = await postVerify(credential); 45 | return isVerified; 46 | } 47 | -------------------------------------------------------------------------------- /src/views/base.js: -------------------------------------------------------------------------------- 1 | export default function wrapHTML(html) { 2 | return ` 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Dock Auth 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | ${html} 19 |
20 |
21 | 22 | 23 | `; 24 | } 25 | -------------------------------------------------------------------------------- /src/views/error.js: -------------------------------------------------------------------------------- 1 | import wrapHTML from './base'; 2 | 3 | export default function getPageHTML(message, redirectTo = 'https://dock.io') { 4 | return wrapHTML(` 5 |

6 | Something went wrong 7 |

8 |

9 | ${message || 'Unknown error'} 10 |

11 | 12 | Back to safety 13 | 14 | `); 15 | } 16 | -------------------------------------------------------------------------------- /src/views/scan-qr.js: -------------------------------------------------------------------------------- 1 | import QRCode from 'qrcode'; 2 | 3 | import { APP_STORE_URI, GPLAY_STORE_URI } from '../config'; 4 | import sanitize from '../utils/sanitize'; 5 | import wrapHTML from './base'; 6 | 7 | export default async function getPageHTML(query, qrUrl, clientInfo) { 8 | const qrData = await QRCode.toDataURL(qrUrl); 9 | const name = sanitize(clientInfo.name); 10 | const website = sanitize(clientInfo.website); 11 | return wrapHTML(` 12 |

13 | Sign in to ${name} 14 |

15 |

16 | You can sign in to ${name} 17 | with your Web3 ID.
18 | Scan the QR code with your Truvera Wallet app or click the button to continue. 19 |

20 |
21 | qr-code 22 |
23 | 31 | 32 | 33 | Sign in with Truvera Wallet 34 | 35 |
36 | 39 |
40 |

Get Truvera Wallet

41 | 49 |
50 |
51 | `); 52 | } 53 | -------------------------------------------------------------------------------- /tests/__mocks__/axios.js: -------------------------------------------------------------------------------- 1 | import mockAxios from 'jest-mock-axios'; 2 | 3 | export default mockAxios; 4 | -------------------------------------------------------------------------------- /tests/__mocks__/memjs.js: -------------------------------------------------------------------------------- 1 | const memjs = jest.createMockFromModule('memjs'); 2 | 3 | class MockClient { 4 | constructor() { 5 | this.get = jest.fn(this.mockGet.bind(this)); 6 | this.set = jest.fn(this.mockSet.bind(this)); 7 | this.delete = jest.fn(this.mockDelete.bind(this)); 8 | this.store = {}; 9 | } 10 | 11 | mockGet(id) { 12 | return { 13 | value: this.store[id], 14 | }; 15 | } 16 | 17 | mockSet(id, value) { 18 | this.store[id] = value; 19 | } 20 | 21 | mockDelete(id) { 22 | delete this.store[id]; 23 | } 24 | 25 | reset() { 26 | this.store = {}; 27 | } 28 | } 29 | 30 | const client = new MockClient(); 31 | 32 | memjs.Client = { 33 | create: jest.fn(() => client), 34 | }; 35 | 36 | memjs.mockReset = () => { 37 | client.reset(); 38 | }; 39 | 40 | module.exports = memjs; 41 | -------------------------------------------------------------------------------- /tests/integration/pages/authorize.test.js: -------------------------------------------------------------------------------- 1 | import memjs from 'memjs'; 2 | import { createMocks } from 'node-mocks-http'; 3 | 4 | import handleAuthorize from '../../../pages/api/oauth2/authorize'; 5 | import { WALLET_APP_URI, APP_STORE_URI, GPLAY_STORE_URI } from '../../../src/config'; 6 | import { authQueryProps, expectedSubmitUri } from './fixtures'; 7 | import { submitCredential } from './helpers'; 8 | 9 | jest.mock('memjs'); 10 | 11 | describe('API Route - /oauth2/authorize', () => { 12 | afterEach(() => { 13 | memjs.mockReset(); 14 | }); 15 | 16 | test('returns redirect URI after validation', async () => { 17 | const { req, res } = createMocks({ 18 | method: 'GET', 19 | query: authQueryProps, 20 | }); 21 | 22 | await handleAuthorize(req, res); 23 | await submitCredential(); 24 | 25 | const resultMock = createMocks({ 26 | method: 'GET', 27 | query: authQueryProps, 28 | }); 29 | await handleAuthorize(req, resultMock.res); 30 | 31 | expect(JSON.parse(resultMock.res._getData()).redirect).toBeDefined(); 32 | }); 33 | 34 | test('redirects request after validation', async () => { 35 | const { req, res } = createMocks({ 36 | method: 'GET', 37 | headers: { 38 | Accept: 'text/html', 39 | }, 40 | query: authQueryProps, 41 | }); 42 | 43 | await handleAuthorize(req, res); 44 | await submitCredential(); 45 | 46 | const resultMock = createMocks({ 47 | method: 'GET', 48 | query: authQueryProps, 49 | }); 50 | await handleAuthorize(req, resultMock.res); 51 | 52 | expect(resultMock.res._getRedirectUrl()).toBeDefined(); 53 | }); 54 | 55 | test('returns JSON with submit URI when requesting JSON', async () => { 56 | const { req, res } = createMocks({ 57 | method: 'GET', 58 | query: authQueryProps, 59 | }); 60 | 61 | await handleAuthorize(req, res); 62 | 63 | expect(res._getStatusCode()).toBe(200); 64 | expect(JSON.parse(res._getData())).toEqual( 65 | expect.objectContaining({ 66 | submitUrl: expectedSubmitUri, 67 | }) 68 | ); 69 | }); 70 | 71 | test('returns a HTML page that contains QR code, auth button and app store links', async () => { 72 | const { req, res } = createMocks({ 73 | method: 'GET', 74 | headers: { 75 | Accept: 'text/html', 76 | }, 77 | query: authQueryProps, 78 | }); 79 | 80 | await handleAuthorize(req, res); 81 | 82 | expect(res._getStatusCode()).toBe(200); 83 | const html = res._getData(); 84 | 85 | const body = document.createElement('div'); 86 | body.innerHTML = html; 87 | 88 | const loginLink = body.querySelector('.submit-btn'); 89 | expect(loginLink.getAttribute('href')).toBe( 90 | WALLET_APP_URI + encodeURIComponent(expectedSubmitUri) 91 | ); 92 | expect(body.querySelector('img[alt=qr-code]')).toBeDefined(); 93 | 94 | const appStoreLink = body.querySelectorAll('.get-wallet-buttons > a')[0]; 95 | const gplayStoreLink = body.querySelectorAll('.get-wallet-buttons > a')[1]; 96 | expect(appStoreLink.getAttribute('href')).toBe(APP_STORE_URI); 97 | expect(gplayStoreLink.getAttribute('href')).toBe(GPLAY_STORE_URI); 98 | }); 99 | 100 | test('returns JSON error with invalid state', async () => { 101 | const { req, res } = createMocks({ 102 | method: 'GET', 103 | query: { 104 | ...authQueryProps, 105 | state: undefined, 106 | client_id: undefined, 107 | response_type: undefined, 108 | redirect_uri: undefined, 109 | }, 110 | }); 111 | 112 | await handleAuthorize(req, res); 113 | 114 | expect(res._getStatusCode()).toBe(400); 115 | expect(res._getData()).toEqual('Invalid client ID'); 116 | }); 117 | 118 | test('returns JSON error with invalid auth request', async () => { 119 | const { req, res } = createMocks({ 120 | method: 'GET', 121 | query: { 122 | ...authQueryProps, 123 | state: undefined, 124 | response_type: undefined, 125 | }, 126 | }); 127 | 128 | await handleAuthorize(req, res); 129 | 130 | expect(res._getStatusCode()).toBe(400); 131 | expect(res._getData()).toEqual('Not a valid auth request'); 132 | }); 133 | 134 | test('returns JSON error with invalid redirect URI', async () => { 135 | const { req, res } = createMocks({ 136 | method: 'GET', 137 | query: { 138 | ...authQueryProps, 139 | redirect_uri: 'https://google.com', 140 | }, 141 | }); 142 | 143 | await handleAuthorize(req, res); 144 | 145 | expect(res._getStatusCode()).toBe(400); 146 | expect(res._getData()).toEqual('Invalid redirect URI'); 147 | }); 148 | 149 | test('returns HTML error with invalid state', async () => { 150 | const { req, res } = createMocks({ 151 | method: 'GET', 152 | headers: { 153 | Accept: 'text/html', 154 | }, 155 | query: { 156 | ...authQueryProps, 157 | state: undefined, 158 | client_id: undefined, 159 | response_type: undefined, 160 | redirect_uri: undefined, 161 | }, 162 | }); 163 | 164 | await handleAuthorize(req, res); 165 | 166 | expect(res._getStatusCode()).toBe(200); 167 | const html = res._getData(); 168 | const body = document.createElement('div'); 169 | body.innerHTML = html; 170 | 171 | expect(body.querySelector('p').innerHTML.trim()).toEqual('Invalid client ID'); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /tests/integration/pages/fixtures.js: -------------------------------------------------------------------------------- 1 | import { SERVER_URL } from '../../../src/config'; 2 | import { encodeClientId, createClientSecret } from '../../../src/utils/client-crypto'; 3 | 4 | export const clientInfo = { 5 | name: 'Test App', 6 | website: 'http://localhost:3000', 7 | redirect_uris: ['http://localhost:3000/login/callback'], 8 | }; 9 | 10 | const clientId = encodeClientId(clientInfo); 11 | 12 | export const authQueryProps = { 13 | response_type: 'code', 14 | redirect_uri: 'http://localhost:3000/login/callback', 15 | state: 'LAO-aLl19QgZ4ZcdSj3EQMYDziuYUnAj', 16 | client_id: clientId, 17 | prompt: 'login', 18 | scope: 'public', 19 | client_secret: createClientSecret(clientId), 20 | }; 21 | 22 | export const authStateID = `${authQueryProps.client_id.substr(0, 8)}${authQueryProps.state}`; 23 | 24 | export const expectedSubmitUri = `${SERVER_URL}/verify?id=${authStateID}&scope=public&client_name=${encodeURIComponent( 25 | clientInfo.name 26 | )}&client_website=${encodeURIComponent(clientInfo.website)}`; 27 | 28 | export const defaultSubject = { 29 | name: 'John Doe', 30 | email: 'test@dock.io', 31 | }; 32 | 33 | export const issuer = 'did:dock:5HPgr7Wgd6RK9LfRwAbqrgfogSqypVuAYwuAi6jnstLAkAyH'; 34 | 35 | export function getMockCredential(state) { 36 | return { 37 | '@context': [ 38 | 'https://www.w3.org/2018/credentials/v1', 39 | { 40 | dk: 'https://ld.dock.io/credentials#', 41 | DockAuthCredential: 'dk:DockAuthCredential', 42 | name: 'dk:name', 43 | email: 'dk:email', 44 | state: 'dk:state', 45 | }, 46 | ], 47 | id: 'didauth:dock:clientid', 48 | type: ['VerifiableCredential', 'DockAuthCredential'], 49 | credentialSubject: { 50 | ...defaultSubject, 51 | state, 52 | }, 53 | issuanceDate: '2022-04-01T18:26:21.637Z', 54 | expirationDate: '2025-04-01T18:26:21.637Z', 55 | proof: { 56 | type: 'Sr25519Signature2020', 57 | created: '2022-05-06T21:57:17Z', 58 | verificationMethod: 'did:dock:5HPgr7Wgd6RK9LfRwAbqrgfogSqypVuAYwuAi6jnstLAkAyH#keys-1', 59 | proofPurpose: 'assertionMethod', 60 | proofValue: 61 | 'z3HDwoXLbwANagGF2wePVvbb4rWi852yvE6a6NQxXxE9hk1q1CT8FZnYuY9LEL6BCpQJKDrvUVv1MwXFx1dG8vfJw', 62 | }, 63 | issuer, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /tests/integration/pages/helpers.js: -------------------------------------------------------------------------------- 1 | import { createMocks } from 'node-mocks-http'; 2 | import mockAxios from 'jest-mock-axios'; 3 | 4 | import handleAuthorize from '../../../pages/api/oauth2/authorize'; 5 | import handleVerify from '../../../pages/api/verify'; 6 | import { getMockCredential, authQueryProps, authStateID } from './fixtures'; 7 | 8 | export async function getAccessToken(authParams) { 9 | const { req, res } = createMocks({ 10 | method: 'POST', 11 | headers: { 12 | 'content-type': 'application/x-www-form-urlencoded', 13 | 'transfer-encoding': 'chunked', 14 | }, 15 | body: { 16 | client_id: authQueryProps.client_id, 17 | client_secret: authQueryProps.client_secret, 18 | redirect_uri: authQueryProps.redirect_uri, 19 | grant_type: 'authorization_code', 20 | scope: 'public', 21 | ...authParams, 22 | }, 23 | }); 24 | 25 | return { req, res }; 26 | } 27 | 28 | export async function submitCredential(verified = true) { 29 | const { req, res } = createMocks({ 30 | method: 'POST', 31 | query: { 32 | id: authStateID, 33 | }, 34 | body: { 35 | vc: getMockCredential(authStateID), 36 | }, 37 | }); 38 | 39 | const promise = handleVerify(req, res); 40 | 41 | // Mock response for credential verification 42 | setTimeout(() => { 43 | mockAxios.mockResponse({ data: { verified } }); 44 | }, 500); 45 | 46 | await promise; 47 | } 48 | 49 | export async function createAuthRequest() { 50 | const { req, res } = createMocks({ 51 | method: 'GET', 52 | query: authQueryProps, 53 | }); 54 | await handleAuthorize(req, res); 55 | 56 | const data = JSON.parse(res._getData()); 57 | if (data.redirect) { 58 | const redirectURL = new URL(data.redirect); 59 | return { 60 | code: redirectURL.searchParams.get('code'), 61 | state: redirectURL.searchParams.get('state'), 62 | }; 63 | } 64 | 65 | return {}; 66 | } 67 | -------------------------------------------------------------------------------- /tests/integration/pages/index.test.js: -------------------------------------------------------------------------------- 1 | import { createMocks } from 'node-mocks-http'; 2 | 3 | import handleIndex from '../../../pages/api/index'; 4 | 5 | describe('API Route - /', () => { 6 | test('status is good', async () => { 7 | const { req, res } = createMocks({ 8 | method: 'GET', 9 | }); 10 | 11 | await handleIndex(req, res); 12 | 13 | const result = JSON.parse(res._getData()); 14 | expect(result.status).toEqual('good'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/integration/pages/register.test.js: -------------------------------------------------------------------------------- 1 | import memjs from 'memjs'; 2 | import { createMocks } from 'node-mocks-http'; 3 | 4 | import handleRegister from '../../../pages/api/register'; 5 | 6 | jest.mock('memjs'); 7 | 8 | const registerOptions = { 9 | redirect_uris: ['http://localhost:3000/login/callback'], 10 | name: 'Test App', 11 | website: 'http://localhost:3000', 12 | }; 13 | 14 | describe('API Route - /register', () => { 15 | afterEach(() => { 16 | memjs.mockReset(); 17 | }); 18 | 19 | test('can register a new client to get id and secret', async () => { 20 | const { req, res } = createMocks({ 21 | method: 'POST', 22 | body: registerOptions, 23 | }); 24 | 25 | await handleRegister(req, res); 26 | 27 | expect(res._getStatusCode()).toBe(200); 28 | expect(res._isJSON()).toBe(true); 29 | 30 | const data = JSON.parse(res._getData()); 31 | expect(data.client_id).toBeDefined(); 32 | expect(data.client_secret).toBeDefined(); 33 | }); 34 | 35 | test('only POST method is supported', async () => { 36 | const { req, res } = createMocks({ 37 | method: 'GET', 38 | }); 39 | await handleRegister(req, res); 40 | expect(res._getStatusCode()).toBe(400); 41 | }); 42 | 43 | test('can not register with no parameters', async () => { 44 | const { req, res } = createMocks({ 45 | method: 'POST', 46 | body: { 47 | redirect_uris: [], 48 | name: '', 49 | website: '', 50 | }, 51 | }); 52 | await handleRegister(req, res); 53 | expect(res._getStatusCode()).toBe(400); 54 | }); 55 | 56 | test('can not register with invalid redirect_uris (not an array)', async () => { 57 | const { req, res } = createMocks({ 58 | method: 'POST', 59 | body: { 60 | ...registerOptions, 61 | redirect_uris: 'astring', 62 | }, 63 | }); 64 | await handleRegister(req, res); 65 | expect(res._getStatusCode()).toBe(400); 66 | }); 67 | 68 | test('can not register with invalid redirect_uris (not a uri)', async () => { 69 | const { req, res } = createMocks({ 70 | method: 'POST', 71 | body: { 72 | ...registerOptions, 73 | redirect_uris: ['astring'], 74 | }, 75 | }); 76 | await handleRegister(req, res); 77 | expect(res._getStatusCode()).toBe(400); 78 | }); 79 | 80 | test('can not register with invalid redirect_uris (multiple)', async () => { 81 | const { req, res } = createMocks({ 82 | method: 'POST', 83 | body: { 84 | ...registerOptions, 85 | redirect_uris: ['http://localhost', 'https://dock.io'], 86 | }, 87 | }); 88 | await handleRegister(req, res); 89 | expect(res._getStatusCode()).toBe(400); 90 | }); 91 | 92 | test('can not register with invalid website', async () => { 93 | const { req, res } = createMocks({ 94 | method: 'POST', 95 | body: { 96 | ...registerOptions, 97 | website: 'notauri', 98 | }, 99 | }); 100 | await handleRegister(req, res); 101 | expect(res._getStatusCode()).toBe(400); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /tests/integration/pages/token.test.js: -------------------------------------------------------------------------------- 1 | import memjs from 'memjs'; 2 | 3 | import handleToken from '../../../pages/api/oauth2/token'; 4 | import { createAuthRequest, submitCredential, getAccessToken } from './helpers'; 5 | 6 | jest.mock('memjs'); 7 | 8 | describe('API Route - /oauth2/token', () => { 9 | let authParams; 10 | 11 | afterEach(() => { 12 | memjs.mockReset(); 13 | }); 14 | 15 | beforeEach(async () => { 16 | await createAuthRequest(); 17 | await submitCredential(); 18 | authParams = await createAuthRequest(); 19 | }); 20 | 21 | test('receives a valid code and returns an auth token', async () => { 22 | const { req, res } = await getAccessToken(authParams); 23 | await handleToken(req, res); 24 | 25 | expect(res._isJSON()).toBe(true); 26 | 27 | const tokenData = JSON.parse(res._getData()); 28 | expect(tokenData.access_token).toBeDefined(); 29 | expect(tokenData.token_type).toBeDefined(); 30 | expect(tokenData.expires_in).toBeDefined(); 31 | expect(tokenData.refresh_token).toBeDefined(); 32 | }); 33 | 34 | test('error state', async () => { 35 | const { req, res } = await getAccessToken({ 36 | ...authParams, 37 | client_secret: null, 38 | }); 39 | await handleToken(req, res); 40 | expect(res._getStatusCode()).toBe(400); 41 | expect(res._getData()).toEqual('Invalid client: cannot retrieve client credentials'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/integration/pages/userinfo.test.js: -------------------------------------------------------------------------------- 1 | import memjs from 'memjs'; 2 | import { createMocks } from 'node-mocks-http'; 3 | 4 | import handleUserInfo from '../../../pages/api/oauth2/userinfo'; 5 | import handleToken from '../../../pages/api/oauth2/token'; 6 | 7 | import { createAuthRequest, submitCredential, getAccessToken } from './helpers'; 8 | import { defaultSubject } from './fixtures'; 9 | 10 | jest.mock('memjs'); 11 | 12 | async function getToken(authParams) { 13 | const { req, res } = await getAccessToken(authParams); 14 | await handleToken(req, res); 15 | 16 | const tokenData = JSON.parse(res._getData()); 17 | return tokenData.access_token; 18 | } 19 | 20 | describe('API Route - /oauth2/userinfo', () => { 21 | let authParams; 22 | 23 | afterEach(() => { 24 | memjs.mockReset(); 25 | }); 26 | 27 | beforeEach(async () => { 28 | await createAuthRequest(); 29 | await submitCredential(); 30 | authParams = await createAuthRequest(); 31 | }); 32 | 33 | test('retrieves user info with valid access token', async () => { 34 | const accessToken = await getToken(authParams); 35 | expect(accessToken).toBeDefined(); 36 | 37 | const { req, res } = createMocks({ 38 | method: 'GET', 39 | query: { 40 | access_token: accessToken, 41 | }, 42 | }); 43 | 44 | await handleUserInfo(req, res); 45 | 46 | const profile = JSON.parse(res._getData()); 47 | expect(profile.name).toEqual(defaultSubject.name); 48 | expect(profile.email).toEqual(defaultSubject.email); 49 | }); 50 | 51 | test('error state', async () => { 52 | const accessToken = await getToken(authParams); 53 | expect(accessToken).toBeDefined(); 54 | 55 | const { req, res } = createMocks({ 56 | method: 'GET', 57 | query: { 58 | access_token: null, 59 | }, 60 | }); 61 | 62 | await handleUserInfo(req, res); 63 | 64 | expect(res._getStatusCode()).toBe(400); 65 | expect(res._getData()).toEqual('Unauthorized request: no authentication given'); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/integration/pages/verify.test.js: -------------------------------------------------------------------------------- 1 | import memjs from 'memjs'; 2 | import { createMocks } from 'node-mocks-http'; 3 | import mockAxios from 'jest-mock-axios'; 4 | 5 | import handleVerify from '../../../pages/api/verify'; 6 | import { DOCK_API_VERIFY_URL } from '../../../src/config'; 7 | 8 | import { getMockCredential, authStateID, issuer } from './fixtures'; 9 | import { createAuthRequest } from './helpers'; 10 | 11 | jest.mock('memjs'); 12 | 13 | describe('API Route - /oauth2/verify', () => { 14 | afterEach(() => { 15 | mockAxios.reset(); 16 | memjs.mockReset(); 17 | }); 18 | 19 | test('verifies a valid auth credential', async () => { 20 | await createAuthRequest(); 21 | const vc = getMockCredential(authStateID); 22 | 23 | { 24 | const { req, res } = createMocks({ 25 | method: 'POST', 26 | query: { 27 | id: authStateID, 28 | }, 29 | body: { 30 | vc, 31 | }, 32 | }); 33 | 34 | const promise = handleVerify(req, res); 35 | 36 | // Mock response for credential verification 37 | setTimeout(() => { 38 | mockAxios.mockResponse({ data: { verified: true } }); 39 | }, 500); 40 | 41 | await promise; 42 | 43 | expect(mockAxios.post).toHaveBeenCalledWith(DOCK_API_VERIFY_URL, vc, { 44 | headers: { 45 | 'DOCK-API-TOKEN': undefined, 46 | }, 47 | }); 48 | 49 | // Expect first time request returns successful 50 | expect(res._getStatusCode()).toBe(200); 51 | expect(JSON.parse(res._getData()).verified).toEqual(true); 52 | expect(JSON.parse(res._getData()).userId).toEqual(issuer); 53 | } 54 | 55 | { 56 | const { req, res } = createMocks({ 57 | method: 'POST', 58 | query: { 59 | id: authStateID, 60 | }, 61 | body: { 62 | vc, 63 | }, 64 | }); 65 | 66 | // Expect that trying to verify immediately again will return success 67 | await handleVerify(req, res); 68 | expect(res._getStatusCode()).toBe(200); 69 | expect(JSON.parse(res._getData()).verified).toEqual(true); 70 | expect(JSON.parse(res._getData()).userId).toEqual(issuer); 71 | } 72 | }); 73 | 74 | test('verifies a valid auth credential with issuer as object', async () => { 75 | await createAuthRequest(); 76 | const vc = getMockCredential(authStateID); 77 | const { req, res } = createMocks({ 78 | method: 'POST', 79 | query: { 80 | id: authStateID, 81 | }, 82 | body: { 83 | vc: { 84 | ...vc, 85 | issuer: { 86 | id: vc.issuer, 87 | }, 88 | }, 89 | }, 90 | }); 91 | 92 | const promise = handleVerify(req, res); 93 | 94 | // Mock response for credential verification 95 | setTimeout(() => { 96 | mockAxios.mockResponse({ data: { verified: true } }); 97 | }, 500); 98 | 99 | await promise; 100 | 101 | // Expect first time request returns successful 102 | expect(res._getStatusCode()).toBe(200); 103 | expect(JSON.parse(res._getData()).verified).toEqual(true); 104 | expect(JSON.parse(res._getData()).userId).toEqual(issuer); 105 | }); 106 | 107 | test('rejects an invalid auth credential', async () => { 108 | await createAuthRequest(); 109 | const vc = getMockCredential(authStateID); 110 | const { req, res } = createMocks({ 111 | method: 'POST', 112 | query: { 113 | id: authStateID, 114 | }, 115 | body: { 116 | vc, 117 | }, 118 | }); 119 | 120 | const promise = handleVerify(req, res); 121 | 122 | // Mock response for credential verification 123 | setTimeout(() => { 124 | mockAxios.mockResponse({ data: { verified: false } }); 125 | }, 500); 126 | 127 | await promise; 128 | 129 | expect(mockAxios.post).toHaveBeenCalledWith(DOCK_API_VERIFY_URL, vc, { 130 | headers: { 131 | 'DOCK-API-TOKEN': undefined, 132 | }, 133 | }); 134 | 135 | expect(res._getStatusCode()).toBe(200); 136 | expect(JSON.parse(res._getData()).verified).toEqual(false); 137 | }); 138 | 139 | test('rejects a non-auth credential', async () => { 140 | await createAuthRequest(); 141 | const vc = getMockCredential(authStateID); 142 | const { req, res } = createMocks({ 143 | method: 'POST', 144 | query: { 145 | id: authStateID, 146 | }, 147 | body: { 148 | vc: { 149 | ...vc, 150 | type: ['VerifiableCredential'], 151 | credentialSubject: { 152 | name: 'John Doe', 153 | }, 154 | }, 155 | }, 156 | }); 157 | 158 | await handleVerify(req, res); 159 | 160 | expect(res._getStatusCode()).toBe(400); 161 | expect(JSON.parse(res._getData()).error).toBeDefined(); 162 | }); 163 | 164 | test('rejects missing post body', async () => { 165 | await createAuthRequest(); 166 | const { req, res } = createMocks({ 167 | method: 'POST', 168 | query: { 169 | id: authStateID, 170 | }, 171 | body: {}, 172 | }); 173 | 174 | await handleVerify(req, res); 175 | 176 | expect(res._getStatusCode()).toBe(400); 177 | expect(JSON.parse(res._getData()).error).toEqual('Missing or invalid post body'); 178 | }); 179 | 180 | test('rejects missing ID', async () => { 181 | await createAuthRequest(); 182 | const vc = getMockCredential(authStateID); 183 | const { req, res } = createMocks({ 184 | method: 'POST', 185 | query: { 186 | id: null, 187 | }, 188 | body: { vc }, 189 | }); 190 | 191 | await handleVerify(req, res); 192 | 193 | expect(res._getStatusCode()).toBe(400); 194 | expect(JSON.parse(res._getData()).error).toEqual('Missing or invalid post body'); 195 | }); 196 | 197 | test('rejects invalid ID', async () => { 198 | await createAuthRequest(); 199 | const vc = getMockCredential(authStateID); 200 | const { req, res } = createMocks({ 201 | method: 'POST', 202 | query: { 203 | id: 'thisisisinvalid', 204 | }, 205 | body: { vc }, 206 | }); 207 | 208 | await handleVerify(req, res); 209 | 210 | expect(res._getStatusCode()).toBe(400); 211 | expect(JSON.parse(res._getData()).error).toEqual( 212 | 'Invalid authorization ID, please go back and try again. (ID: thisisisinvalid)' 213 | ); 214 | }); 215 | 216 | test('rejects non-POST request', async () => { 217 | await createAuthRequest(); 218 | const { req, res } = createMocks({ 219 | method: 'GET', 220 | query: { 221 | id: authStateID, 222 | }, 223 | }); 224 | 225 | await handleVerify(req, res); 226 | 227 | expect(res._getStatusCode()).toBe(400); 228 | expect(JSON.parse(res._getData()).error).toEqual('Missing or invalid post body'); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | -------------------------------------------------------------------------------- /tests/unit/oauth/model.test.js: -------------------------------------------------------------------------------- 1 | import memjs from 'memjs'; 2 | import MemcachedOAuthModel from '../../../src/oauth/model'; 3 | import { encodeClientId } from '../../../src/utils/client-crypto'; 4 | 5 | jest.mock('memjs'); 6 | 7 | describe('Oauth Memcached Model', () => { 8 | const model = new MemcachedOAuthModel(); 9 | 10 | afterEach(() => { 11 | memjs.mockReset(); 12 | }); 13 | 14 | test('invalid getClient returns false', async () => { 15 | expect(await model.getClient('invalidid')).toEqual(false); 16 | expect( 17 | await model.getClient( 18 | encodeClientId({ name: 'test', website: 'https://t.com', redirect_uris: ['t://t'] }), 19 | 'invalidsecret' 20 | ) 21 | ).toEqual(false); 22 | }); 23 | 24 | test('getUser returns false', async () => { 25 | expect(await model.getUser()).toEqual(false); 26 | }); 27 | 28 | test('getRefreshToken returns false', async () => { 29 | expect(await model.getRefreshToken('code')).toEqual(false); 30 | }); 31 | 32 | test('getAuthorizationCode returns false when its not valid', async () => { 33 | expect(await model.getAuthorizationCode('code')).toEqual(false); 34 | }); 35 | 36 | test('completeVCCheck returns false when its not valid', async () => { 37 | expect(await model.completeVCCheck('id', {})).toEqual(false); 38 | }); 39 | 40 | test('getAccessToken returns false when its not valid', async () => { 41 | expect(await model.getAccessToken('code')).toEqual(false); 42 | }); 43 | 44 | test('set accepts null value', async () => { 45 | expect(await model.set('type', 'id', null)).toBeDefined(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/unit/placeholder.test.js: -------------------------------------------------------------------------------- 1 | describe('e2e', () => { 2 | test('placeholder', () => { 3 | expect(true).toEqual(true); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/unit/utils/client-crypto.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | encodeClientId, 3 | createClientSecret, 4 | isValidClientSecret, 5 | getHash, 6 | cleanInput, 7 | decodeClientID, 8 | encrypt, 9 | CLIENT_ID_PREFIX, 10 | } from '../../../src/utils/client-crypto'; 11 | 12 | const CRYPTO_KEY = process.env.CRYPTO_KEY || '6352e481f4338d176352e481f4338d17'; 13 | 14 | describe('Utils - client crypto', () => { 15 | let clientId; 16 | let clientSecret; 17 | beforeAll(() => { 18 | clientId = encodeClientId({ name: 'test', website: 'https://t.com', redirect_uris: ['t://t'] }); 19 | clientSecret = createClientSecret(clientId); 20 | }); 21 | 22 | test('decodeClientID works', () => { 23 | expect(!!decodeClientID(clientId)).toEqual(true); 24 | expect(!!decodeClientID(encrypt('thisisaninvalidclientid', CRYPTO_KEY))).toEqual(false); 25 | expect( 26 | !!decodeClientID(encrypt(`${CLIENT_ID_PREFIX}thisisaninvalidclientid`, CRYPTO_KEY)) 27 | ).toEqual(false); 28 | }); 29 | 30 | test('isValidClientSecret works', () => { 31 | expect(isValidClientSecret(clientId, clientSecret)).toEqual(true); 32 | expect(isValidClientSecret('invalidclientid', clientSecret)).toEqual(false); 33 | expect(isValidClientSecret(clientId, 'non encrypted client secret')).toEqual(false); 34 | expect( 35 | isValidClientSecret(clientId, encrypt('thisisaninvalidclientsecret', CRYPTO_KEY)) 36 | ).toEqual(false); 37 | expect( 38 | isValidClientSecret( 39 | clientId, 40 | '3ToCiUksf0JpfccX6P4RYfcXgHO2vNML2dhbNPWf6QxT9taLDEbbw6cGObrYqc9' 41 | ) 42 | ).toEqual(false); 43 | }); 44 | 45 | test('getHash works', () => { 46 | expect(getHash('test')).toEqual('098f6bcd4621d373cade4e832627b4f6'); 47 | }); 48 | 49 | test('cleanInput works', () => { 50 | expect(cleanInput('a\nb\nc')).toEqual('abc'); 51 | expect(cleanInput('a\n b\nc ')).toEqual('a bc'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/unit/utils/request-validation.test.js: -------------------------------------------------------------------------------- 1 | import isValidAuthRequest from '../../../src/utils/request-validation'; 2 | 3 | const validQuery = { 4 | state: 'defined', 5 | client_id: '123', 6 | response_type: 'code', 7 | redirect_uri: 'http://dock.io', 8 | }; 9 | 10 | describe('Utils - isValidAuthRequest', () => { 11 | test('returns true for a valid auth request', () => { 12 | expect( 13 | isValidAuthRequest({ 14 | query: validQuery, 15 | }) 16 | ).toEqual(true); 17 | }); 18 | 19 | test('returns false for no query', () => { 20 | expect(isValidAuthRequest({})).toEqual(false); 21 | }); 22 | 23 | test('returns false for undefined state', () => { 24 | expect( 25 | isValidAuthRequest({ 26 | query: { 27 | ...validQuery, 28 | state: undefined, 29 | }, 30 | }) 31 | ).toEqual(false); 32 | }); 33 | 34 | test('returns false for undefined client_id', () => { 35 | expect( 36 | isValidAuthRequest({ 37 | query: { 38 | ...validQuery, 39 | client_id: undefined, 40 | }, 41 | }) 42 | ).toEqual(false); 43 | }); 44 | 45 | test('returns false for undefined response_type', () => { 46 | expect( 47 | isValidAuthRequest({ 48 | query: { 49 | ...validQuery, 50 | response_type: undefined, 51 | }, 52 | }) 53 | ).toEqual(false); 54 | }); 55 | 56 | test('returns false for undefined redirect_uri', () => { 57 | expect( 58 | isValidAuthRequest({ 59 | query: { 60 | ...validQuery, 61 | redirect_uri: undefined, 62 | }, 63 | }) 64 | ).toEqual(false); 65 | }); 66 | 67 | test('returns false for non-string state', () => { 68 | expect( 69 | isValidAuthRequest({ 70 | query: { 71 | ...validQuery, 72 | state: { object: true }, 73 | }, 74 | }) 75 | ).toEqual(false); 76 | }); 77 | 78 | test('returns false for non-string client_id', () => { 79 | expect( 80 | isValidAuthRequest({ 81 | query: { 82 | ...validQuery, 83 | client_id: { object: true }, 84 | }, 85 | }) 86 | ).toEqual(false); 87 | }); 88 | 89 | test('returns false for non-string response_type', () => { 90 | expect( 91 | isValidAuthRequest({ 92 | query: { 93 | ...validQuery, 94 | response_type: { object: true }, 95 | }, 96 | }) 97 | ).toEqual(false); 98 | }); 99 | 100 | test('returns false for non-string redirect_uri', () => { 101 | expect( 102 | isValidAuthRequest({ 103 | query: { 104 | ...validQuery, 105 | redirect_uri: { object: true }, 106 | }, 107 | }) 108 | ).toEqual(false); 109 | }); 110 | 111 | test('returns false for invalid response type', () => { 112 | expect( 113 | isValidAuthRequest({ 114 | query: { 115 | ...validQuery, 116 | response_type: 'token', 117 | }, 118 | }) 119 | ).toEqual(false); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /tests/unit/utils/sanitize.test.js: -------------------------------------------------------------------------------- 1 | import sanitize from '../../../src/utils/sanitize'; 2 | 3 | describe('Utils - sanitize', () => { 4 | test('sanitizes html', () => { 5 | expect(sanitize('&')).toEqual('&'); 6 | expect(sanitize('<')).toEqual('<'); 7 | expect(sanitize('>')).toEqual('>'); 8 | }); 9 | 10 | test('sanitizes quotes', () => { 11 | expect(sanitize('"')).toEqual('"'); 12 | expect(sanitize("'")).toEqual('''); 13 | }); 14 | 15 | test('doesnt sanitize non-strings', () => { 16 | expect(sanitize({})).toEqual(''); 17 | expect(sanitize(null)).toEqual(''); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/unit/utils/valid-url.test.js: -------------------------------------------------------------------------------- 1 | import isValidHttpUrl from '../../../src/utils/valid-url'; 2 | 3 | describe('Utils - isValidHttpUrl', () => { 4 | test('isValidHttpUrl happy path', () => { 5 | expect(isValidHttpUrl('http://google.com')).toEqual(true); 6 | expect(isValidHttpUrl('https://google.com')).toEqual(true); 7 | expect(isValidHttpUrl('https://localhost')).toEqual(true); 8 | expect(isValidHttpUrl('https://localhost:3000')).toEqual(true); 9 | expect(isValidHttpUrl('https://dock.io/login/auth/callback')).toEqual(true); 10 | }); 11 | 12 | test('isValidHttpUrl sad path', () => { 13 | expect(isValidHttpUrl('did:dock:xyz')).toEqual(false); 14 | expect(isValidHttpUrl('notaurl')).toEqual(false); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/unit/utils/verify-credential.test.js: -------------------------------------------------------------------------------- 1 | import mockAxios from 'jest-mock-axios'; 2 | import { ensureAuthCredential, postVerify } from '../../../src/utils/verify-credential'; 3 | import { DOCK_API_VERIFY_URL } from '../../../src/config'; 4 | 5 | const credentialId = 'credential:auth'; 6 | const credential = { 7 | type: ['VerifiableCredential', 'DockAuthCredential'], 8 | credentialSubject: { 9 | state: credentialId, 10 | }, 11 | }; 12 | 13 | describe('Utils - postVerify', () => { 14 | afterEach(() => { 15 | mockAxios.reset(); 16 | }); 17 | 18 | test('requests credential veriifcation and returns true if successful', async () => { 19 | const promise = postVerify(credential); 20 | mockAxios.mockResponse({ data: { verified: true } }); 21 | const [result, error] = await promise; 22 | 23 | expect(result).toBe(true); 24 | expect(error).toBe(null); 25 | expect(mockAxios.post).toHaveBeenCalledWith(DOCK_API_VERIFY_URL, credential, { 26 | headers: { 27 | 'DOCK-API-TOKEN': undefined, 28 | }, 29 | }); 30 | }); 31 | 32 | test('requests credential veriifcation and returns false if unsuccessful', async () => { 33 | const promise = postVerify(credential); 34 | mockAxios.mockResponse({ data: { verified: false } }); 35 | const [result, error] = await promise; 36 | 37 | expect(result).toBe(false); 38 | expect(error).toBeDefined(); 39 | expect(mockAxios.post).toHaveBeenCalledWith(DOCK_API_VERIFY_URL, credential, { 40 | headers: { 41 | 'DOCK-API-TOKEN': undefined, 42 | }, 43 | }); 44 | }); 45 | 46 | test('returns false if theres an error', async () => { 47 | const promise = postVerify(credential); 48 | mockAxios.mockError(); 49 | const [result, error] = await promise; 50 | expect(result).toBe(false); 51 | expect(error).toBeDefined(); 52 | }); 53 | }); 54 | 55 | describe('Utils - ensureAuthCredential', () => { 56 | test('throws error for wrong credential type', () => { 57 | expect(() => 58 | ensureAuthCredential(credentialId, { 59 | ...credential, 60 | type: ['VerifiableCredential'], 61 | }) 62 | ).toThrow('Wrong credential type'); 63 | 64 | expect(() => 65 | ensureAuthCredential(credentialId, { 66 | ...credential, 67 | type: undefined, 68 | }) 69 | ).toThrow('Wrong credential type'); 70 | }); 71 | 72 | test('throws error for array subject', () => { 73 | expect(() => 74 | ensureAuthCredential(credentialId, { 75 | ...credential, 76 | credentialSubject: [{}], 77 | }) 78 | ).toThrow('Subject cannot be array'); 79 | }); 80 | 81 | test('throws error for non-object subject', () => { 82 | expect(() => 83 | ensureAuthCredential(credentialId, { 84 | ...credential, 85 | credentialSubject: 'string', 86 | }) 87 | ).toThrow('Subject must be object'); 88 | }); 89 | 90 | test('throws error for no state in subject', () => { 91 | expect(() => 92 | ensureAuthCredential(credentialId, { 93 | ...credential, 94 | credentialSubject: { 95 | noState: true, 96 | }, 97 | }) 98 | ).toThrow('Subject requires state'); 99 | }); 100 | 101 | test('throws error for mismatching state vs id', () => { 102 | expect(() => 103 | ensureAuthCredential(credentialId, { 104 | ...credential, 105 | credentialSubject: { 106 | state: 'wrong state', 107 | }, 108 | }) 109 | ).toThrow('State mismatch'); 110 | }); 111 | 112 | test('happy path', () => { 113 | expect(() => 114 | ensureAuthCredential(credentialId, { 115 | ...credential, 116 | }) 117 | ).not.toThrow(); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /tests/unit/views/error.test.js: -------------------------------------------------------------------------------- 1 | import getErrorHTML from '../../../src/views/error'; 2 | 3 | describe('Views - Error state', () => { 4 | test('shows title, default message and default back link', () => { 5 | const html = getErrorHTML(); 6 | const body = document.createElement('div'); 7 | body.innerHTML = html; 8 | expect(body.querySelector('h1').innerHTML.trim()).toEqual('Something went wrong'); 9 | expect(body.querySelector('p').innerHTML.trim()).toEqual('Unknown error'); 10 | expect(body.querySelector('a').getAttribute('href')).toEqual('https://dock.io'); 11 | }); 12 | 13 | test('shows custom message and default redirect url', () => { 14 | const html = getErrorHTML('my message'); 15 | const body = document.createElement('div'); 16 | body.innerHTML = html; 17 | expect(body.querySelector('p').innerHTML.trim()).toEqual('my message'); 18 | expect(body.querySelector('a').getAttribute('href')).toEqual('https://dock.io'); 19 | }); 20 | 21 | test('shows custom message and redirect url', () => { 22 | const html = getErrorHTML('my message', 'http://back.com'); 23 | const body = document.createElement('div'); 24 | body.innerHTML = html; 25 | expect(body.querySelector('p').innerHTML.trim()).toEqual('my message'); 26 | expect(body.querySelector('a').getAttribute('href')).toEqual('http://back.com'); 27 | }); 28 | }); 29 | --------------------------------------------------------------------------------