Skip to content

Commit

Permalink
feat: magic-link; password-free login (#1150)
Browse files Browse the repository at this point in the history
Co-authored-by: Léo Pradel <pradel.leo@gmail.com>
  • Loading branch information
larsivi and pradel committed Jul 25, 2021
1 parent 4f720e9 commit 2205664
Show file tree
Hide file tree
Showing 74 changed files with 1,828 additions and 10 deletions.
38 changes: 38 additions & 0 deletions .changeset/chatty-frogs-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@accounts/client': minor
'@accounts/client-magic-link': minor
'@accounts/database-manager': minor
'@accounts/mongo': minor
'@accounts/mongo-magic-link': minor
'@accounts/typeorm': minor
'@accounts/graphql-api': minor
'@accounts/graphql-client': minor
'@accounts/magic-link': minor
'@accounts/rest-client': minor
'@accounts/rest-express': minor
'@accounts/server': minor
'@accounts/types': minor
---

Add support for magic-link strategy 🎉.

Installation:

```sh
yarn add @accounts/magic-link
```

Usage:

```js
import AccountsMagicLink from '@accounts/magic-link';

const accountsMagicLink = new AccountsMagicLink({});

const accountsServer = new AccountsServer(
{ db: accountsDb, tokenSecret: 'secret' },
{
magicLink: accountsMagicLink,
}
);
```
38 changes: 38 additions & 0 deletions examples/magic-link-server-typescript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# magic-link-server-typescript

This example demonstrate how to use setup [accounts-js](https://github.com/accounts-js/accounts)' magic link module on the server.

## Setup example

In order to be able to run this example on your machine you first need to do the following steps:

- Clone the repository `git clone git@github.com:accounts-js/accounts.git`
- Install project dependencies: `yarn`
- Link together all the packages: `yarn setup`
- Compile the packages `yarn compile`
- Go to the example folder `cd examples/magic-link-server-typescript`

## Prerequisites

You will need a MongoDB server to run this server. If you don't have a MongoDB server running already, and you have Docker & Docker Compose, you can do

```bash
docker-compose up -d
```

to start a new one.

## Getting Started

This example sets up the module, then proceeds to run a simple test, before exiting. See the graphql-server-typescript example
for a more comprehensive example of general accounts use.

To run the example:

```bash
yarn start
```

You should see output on the console suggesting that authenticating using a token was
a success. There are quite a few comments in the code saying something about best practice
if using the lower level functionality.
7 changes: 7 additions & 0 deletions examples/magic-link-server-typescript/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: '3.6'
services:
db-mongo-accounts:
image: mongo:3.6.5-jessie
ports:
- '27017:27017'
restart: always
27 changes: 27 additions & 0 deletions examples/magic-link-server-typescript/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@examples/magic-link-server-typescript",
"private": true,
"version": "0.32.0",
"main": "lib/index.js",
"license": "MIT",
"scripts": {
"start": "NODE_ENV=development nodemon -w src -x ts-node src/index.ts",
"build": "tsc",
"test": "yarn build"
},
"dependencies": {
"@accounts/database-manager": "^0.32.0",
"@accounts/mongo": "^0.32.0",
"@accounts/server": "^0.32.0",
"@accounts/magic-link": "^0.1.0",
"mongoose": "5.9.25",
"tslib": "2.1.0"
},
"devDependencies": {
"@types/mongoose": "5.7.32",
"@types/node": "14.0.13",
"nodemon": "2.0.4",
"ts-node": "8.10.2",
"typescript": "3.9.7"
}
}
65 changes: 65 additions & 0 deletions examples/magic-link-server-typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import mongoose from 'mongoose';
import MongoDBInterface from '@accounts/mongo';
import { DatabaseManager } from '@accounts/database-manager';
import AccountsMagicLink from '@accounts/magic-link';
import { AccountsServer } from '@accounts/server/lib/accounts-server';

const start = async () => {
await mongoose.connect('mongodb://localhost:27017/accounts-js-magic-link-example', {
useNewUrlParser: true,
});
const mongoConn = mongoose.connection;

// Build a storage for storing users
const userStorage = new MongoDBInterface(mongoConn);
// Create database manager (create user, find users, sessions etc) for accounts-js
const accountsDb = new DatabaseManager({
sessionStorage: userStorage,
userStorage,
});

const accountsMagicLink = new AccountsMagicLink({});

// Create accounts server that holds a lower level of all accounts operations
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const accountsServer = new AccountsServer(
{ db: accountsDb, tokenSecret: 'secret' },
{
magicLink: accountsMagicLink,
}
);

// Setup a user (or use one from a previous run)
const user = await userStorage.findUserByUsername('magnus');

const userId = user
? user.id
: await userStorage.createUser({
email: 'magnus@accounts.js.org',
username: 'magnus',
password: 'secret!123',
});

// We clear previous tokens since an error with such a low quality token as used
// below can cause issues moving on.
await userStorage.removeAllLoginTokens(userId);

// Before a user can use a token to authenticate or login, the token must be
// registered on the user. You can use whichever token generator you want, but
// generateRandomToken available in the server package can be used (and is used)
// internally. The token must be unique as it internally is used to find the user
// during login.
await userStorage.addLoginToken(userId, 'magnus@accounts.js.org', 'goodtoken');

// Here we show that you can authenticate using the token, the function that
// will be used if logging in through the general accounts interface.
const authenticated = await accountsMagicLink.authenticate({ token: 'goodtoken' });

// See in the output that we successfully authenticated the user. If the authentication
// failed, the example will exit with a stacktrace.
if (authenticated) {
console.log('Success! User logged in using token from magic link');
}
};

start();
13 changes: 13 additions & 0 deletions examples/magic-link-server-typescript/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"outDir": "./lib",
"target": "es5",
"lib": ["es2015", "esnext.asynciterable"],
"sourceMap": true,
"importHelpers": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "lib"]
}
21 changes: 21 additions & 0 deletions packages/client-magic-link/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# @accounts/client-magic-link

[![npm](https://img.shields.io/npm/v/@accounts/client-magic-link)](https://www.npmjs.com/package/@accounts/client-magic-link)
[![npm downloads](https://img.shields.io/npm/dm/@accounts/client-magic-link)](https://www.npmjs.com/package/@accounts/client-magic-link)
[![codecov](https://img.shields.io/codecov/c/github/accounts-js/accounts)](https://codecov.io/gh/accounts-js/accounts)
[![License](https://img.shields.io/github/license/accounts-js/accounts)](https://github.com/accounts-js/accounts/blob/master/LICENSE)

## Documentation

- [Website documentation](https://www.accountsjs.com/docs/strategies/magic-link-client)
- [API documentation](https://www.accountsjs.com/docs/api/client-magic-link/globals)

## Installation

```
yarn add @accounts/client-magic-link
```

## Contributing

Any contribution is very welcome, read our [contributing guide](https://github.com/accounts-js/accounts/blob/master/CONTRIBUTING.md) to see how to locally setup the repository and see our development process.
44 changes: 44 additions & 0 deletions packages/client-magic-link/__tests__/client-magic-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AccountsClientMagicLink } from '../src/client-magic-link';

const mockedClient = {
transport: {
requestMagicLinkEmail: jest.fn(),
},
loginWithService: jest.fn(),
};
const accountsMagicLink = new AccountsClientMagicLink(mockedClient as any);

const user = {
email: 'john@doe.com',
token: 'deadbeef',
};

describe('AccountsClientMagicLink', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('requires the client', async () => {
expect(() => new AccountsClientMagicLink(null as any)).toThrowError(
'A valid client instance is required'
);
});

describe('login', () => {
it('should call client', async () => {
await accountsMagicLink.login(user as any);
expect(mockedClient.loginWithService).toHaveBeenCalledTimes(1);
expect(mockedClient.loginWithService).toHaveBeenCalledWith('magic-link', {
email: user.email,
token: user.token,
});
});
});

describe('requestMagicLinkEmail', () => {
it('should call transport', async () => {
await accountsMagicLink.requestMagicLinkEmail(user.email);
expect(mockedClient.transport.requestMagicLinkEmail).toHaveBeenCalledWith(user.email);
});
});
});
47 changes: 47 additions & 0 deletions packages/client-magic-link/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@accounts/client-magic-link",
"version": "0.1.0",
"description": "@accounts/client-magic-link",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"publishConfig": {
"access": "public"
},
"scripts": {
"clean": "rimraf lib",
"start": "tsc --watch",
"precompile": "yarn clean",
"compile": "tsc",
"prepublishOnly": "yarn compile",
"test": "yarn testonly",
"test-ci": "yarn lint && yarn coverage",
"testonly": "jest",
"test:watch": "jest --watch",
"coverage": "yarn testonly --coverage"
},
"files": [
"src",
"lib"
],
"jest": {
"preset": "ts-jest"
},
"repository": {
"type": "git",
"url": "https://github.com/accounts-js/accounts/tree/master/packages/client-magic-link"
},
"author": "Leo Pradel",
"license": "MIT",
"devDependencies": {
"@types/jest": "25.2.3",
"@types/node": "14.0.14",
"jest": "26.6.3",
"rimraf": "3.0.2",
"ts-jest": "26.5.0"
},
"dependencies": {
"@accounts/client": "^0.32.0",
"@accounts/types": "^0.32.0",
"tslib": "2.1.0"
}
}
28 changes: 28 additions & 0 deletions packages/client-magic-link/src/client-magic-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AccountsClient } from '@accounts/client';
import { LoginResult, LoginUserMagicLinkService } from '@accounts/types';

export class AccountsClientMagicLink {
private client: AccountsClient;

constructor(client: AccountsClient) {
if (!client) {
throw new Error('A valid client instance is required');
}
this.client = client;
}

/**
* Log the user in with a token.
*/
public async login(user: LoginUserMagicLinkService): Promise<LoginResult> {
return this.client.loginWithService('magic-link', user);
}

/**
* Request a new login link.
* @param {string} email - The email address to send a login link.
*/
public requestMagicLinkEmail(email: string): Promise<void> {
return this.client.transport.requestMagicLinkEmail(email);
}
}
1 change: 1 addition & 0 deletions packages/client-magic-link/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AccountsClientMagicLink } from './client-magic-link';
10 changes: 10 additions & 0 deletions packages/client-magic-link/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"importHelpers": true,
"target": "es5"
},
"exclude": ["node_modules", "__tests__", "lib"]
}
1 change: 1 addition & 0 deletions packages/client/src/transport-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ export interface TransportInterface {
addEmail(newEmail: string): Promise<void>;
changePassword(oldPassword: string, newPassword: string): Promise<void>;
impersonate(token: string, impersonated: ImpersonationUserIdentity): Promise<ImpersonationResult>;
requestMagicLinkEmail(email: string): Promise<void>;
}
24 changes: 24 additions & 0 deletions packages/database-manager/__tests__/database-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ export default class Database {
public setUserDeactivated() {
return this.name;
}

public findUserByLoginToken() {
return this.name;
}

public addLoginToken() {
return this.name;
}

public removeAllLoginTokens() {
return this.name;
}
}

const databaseManager = new DatabaseManager({
Expand Down Expand Up @@ -243,4 +255,16 @@ describe('DatabaseManager', () => {
it('removeAllResetPasswordTokens should be called on userStorage', () => {
expect(databaseManager.removeAllResetPasswordTokens('userId')).toBe('userStorage');
});

it('addLoginToken should be called on userStorage', () => {
expect(databaseManager.addLoginToken('userId', 'email', 'token')).toBe('userStorage');
});

it('removeAllLoginTokens should be called on userStorage', () => {
expect(databaseManager.removeAllLoginTokens('userId')).toBe('userStorage');
});

it('findUserByLoginToken should be called on userStorage', () => {
expect(databaseManager.findUserByLoginToken('token')).toBe('userStorage');
});
});

0 comments on commit 2205664

Please sign in to comment.