@mercury-labs/auth
Version:
Mercury framework auth library. It supports local auth, jwt with both bearer token and cookie, basic auth.
460 lines (399 loc) • 12.8 kB
Markdown
# Mercury Auth
### A NestJS module package for authentication.
#### Support both `FasitfyAdaptor` and `ExpressAdaptor`.
## Install
```shell
npm install --save -labs/auth
```
Learn more about the relevant package [-labs/hashing](https://www.npmjs.com/package/@mercury-labs/hashing)
## Define a database repository to get user info
```typescript
import {
AUTH_PASSWORD_HASHER,
AuthRepository,
IAuthUserEntity,
PasswordHasherService
} from '-labs/auth'
import { Injectable } from '@nestjs/common'
import moment from 'moment'
import { map, Observable, scheduled } from 'rxjs'
()
export class CmsAuthRepository implements AuthRepository {
public constructor(
(AUTH_PASSWORD_HASHER)
protected readonly hasher: PasswordHasherService
) {
}
public getAuthUserByUsername(
username: string
): Observable<IAuthUserEntity | undefined> {
// Create sample hashed password for demo only
return scheduled(this.hasher.hash('some-password-phrase'))
.pipe(
// Sample user for demo only
map((password: string) => ({
id: '123456',
firstName: 'John Doe',
lastName: '',
email: 'sample-email+dev .com',
password,
createdAt: moment().toDate(),
updatedAt: moment().toDate(),
})),
map((user) => {
if (
user.email !== username
) {
return undefined
}
return {
...user,
username: user.email,
}
})
)
}
}
```
## Register `AuthModule` to your application module
```typescript
import { AuthModule, AuthTransferTokenMethod } from '@mercury-labs/auth'
import { ConfigService } from '@nestjs/config'
import { Module } from '@nestjs/common'
({
imports: [
AuthModule.forRootAsync({
definitions: {
useFactory: (config: ConfigService) => {
return {
basicAuth: {
username: config.get('BASIC_AUTH_USER'),
password: config.get('BASIC_AUTH_PASSWORD'),
},
impersonate: {
isEnabled: config.get('AUTH_IMPERSONATE_ENABLED') === 'true',
cipher: config.get('AUTH_IMPERSONATE_CIPHER'),
password: config.get('AUTH_IMPERSONATE_PASSWORD'),
},
jwt: {
secret: config.get('AUTH_JWT_SECRET'),
expiresIn: config.get('AUTH_JWT_EXPIRES') || '1d',
},
transferTokenMethod: config.get<AuthTransferTokenMethod>(
'AUTH_TRANSFER_TOKEN_METHOD'
),
redactedFields: ['password'],
hashingSecretKey: config.get('HASHING_SECRET_KEY') || '',
usernameField: 'username',
passwordField: 'password',
httpAdaptorType: 'fastify'
}
},
inject: [ConfigService],
},
authRepository: {
useFactory: (hasher: PasswordHasherService) => {
return new CmsAuthRepository(hasher)
},
inject: [AUTH_PASSWORD_HASHER]
}
}),
]
})
export class AppModule {}
```
#### Notes:
```typescript
interface IAuthDefinitions {
/**
* Configuration for basic auth
*/
basicAuth: {
username: string
password: string
/**
* The realm name for WWW-Authenticate header
*/
realm?: string
}
/**
* Configuration for JWT
*/
jwt: {
/**
* Do not expose this key publicly.
* We have done so here to make it clear what the code is doing,
* but in a production system you must protect this key using appropriate measures,
* such as a secrets vault, environment variable, or configuration service.
*/
secret: string
/**
* Expressed in seconds or a string describing a time span zeit/ms.
* @see https://github.com/vercel/ms
* Eg: 60, “2 days”, “10h”, “7d”
*/
expiresIn: string | number
refreshTokenExpiresIn: string | number
}
/**
* Configuration for impersonate login
* You can login to a user account without their password.
* Eg:
* - username: {your_impersonate_cipher_key}username
* - password: {your_impersonate_password}
*/
impersonate?: {
isEnabled: boolean
cipher: string
password: string
}
/**
* Hide some sentitive fields while getting user profile.
*/
redactedFields?: string[]
/**
* These routes will always be PUBLIC.
* No authentication required.
*/
ignoredRoutes?: string[]
/**
* Used to encode/decode the access/refresh token
* 32 characters string
*/
hashingSecretKey: string
/**
* We accepted these 3 values: cookie|bearer|both
* - cookie: after user login, their accessToken and refreshToken will be sent using cookie
* - bearer: after user login, their accessToken and refreshToken will be sent to response body
* - both: mixed those 2 above values.
*/
transferTokenMethod: AuthTransferTokenMethod,
cookieOptions?: {
domain?: string
path?: string // Default '/'
sameSite?: boolean | 'lax' | 'strict' | 'none' // Default true
signed?: boolean
httpOnly?: boolean // Default true
secure?: boolean
},
/**
* Username field when login
* Eg: email, username,...
*/
usernameField?: string
/**
* Password field when login
* Eg: password, pass...
*/
passwordField?: string,
httpAdaptorType: 'fastify' | 'express'
}
```
### Customize your hasher method
By default, I use `bcrypt` to encode and compare password hash.
In some case, you might need to change the way or algorithm to hash the
password.
#### Create new hasher class
```typescript
import crypto from 'crypto'
import { PasswordHasherService } from '@mercury-labs/auth'
import { Injectable } from '@nestjs/common'
export interface IPbkdf2Hash {
hash: string
salt: string
}
()
export class Pbkdf2PasswordHasherService implements PasswordHasherService<IPbkdf2Hash> {
public async hash(password: string): Promise<IPbkdf2Hash> {
const salt = crypto.randomBytes(16).toString('hex')
const hash = crypto.pbkdf2Sync(
password,
salt,
10000,
512,
'sha512'
).toString('hex')
return { salt, hash }
}
public async compare(password: string, hashedPassword: IPbkdf2Hash): Promise<boolean> {
const hashPassword = crypto.pbkdf2Sync(
password,
hashedPassword.salt,
10000,
512,
'sha512'
).toString('hex')
return hashedPassword.hash === hashPassword
}
}
```
#### Register it to `AuthModule`
```typescript
AuthModule.forRootAsync({
...,
passwordHasher: {
useFactory: () => {
return new Pbkdf2PasswordHasherService()
},
}
})
```
#### Sample updated `CmsAuthRepository`
```typescript
()
export class CmsAuthRepository implements AuthRepository {
public constructor(
(AUTH_PASSWORD_HASHER)
protected readonly hasher: PasswordHasherService<IPbkdf2Hash>
) {
}
public getAuthUserByUsername(
username: string
): Observable<IAuthUserEntity | undefined> {
// Create sample hashed password for demo only
return scheduled(this.hasher.hash('some-password-phrase'))
.pipe(
// Sample user for demo only
map((password: IPbkdf2Hash) => ({
id: '123456',
firstName: 'John Doe',
lastName: '',
email: 'sample-email+dev .com',
password,
createdAt: moment().toDate(),
updatedAt: moment().toDate(),
})),
map((user) => {
if (
user.email !== username
) {
return undefined
}
return {
...user,
username: user.email,
}
})
)
}
}
```
#### Access the login route
curl
```
curl --request POST \
--url http://localhost:4005/auth/login \
--header 'Content-Type: application/json' \
--data '{
"username": "sample-email+dev@gmail.com",
"password": "some-password-phrase"
}'
```
#### Refresh your access token
curl
```
curl --request POST \
--url http://localhost:4005/auth/refresh-token \
--header 'Refresh-Token: eyJpdiI6IjFmNTY4ZWZmN2RmODRmZjkxNjQx...'
```
#### Get your logged in user profile
curl
```
curl --request GET \
--url http://localhost:4005/auth/profile \
--header 'Authorization: Bearer eyJpdiI6IjFmNTY4ZWZmN2RmODRmZjkxNjQx...'
```
#### Logout
curl
```
curl --request POST \
--url http://localhost:4005/auth/logout
--header 'Authorization: Bearer eyJpdiI6IjFmNTY4ZWZmN2RmODRmZjkxNjQx...'
```
### Injection Decorators
` ()`: inject `IAuthDefinitions` to your injectable classes.
` ()`: inject `PasswordHasherService` to your injectable classes.
### Controller Decorators
` ()` This decorator will help your controller available for all users. No authentication required.
```typescript
import { Public } from '@mercury-labs/auth'
import { Controller, Get } from '@nestjs/common'
()
()
export class AppController {
()
public getHello(): string {
return 'Hello World!'
}
}
```
` ()` You need to use basic auth while accessing your controller.
```typescript
import { InternalOnly } from '@mercury-labs/auth'
import { Controller, Get } from '@nestjs/common'
()
()
export class AppController {
()
public getHello(): string {
return 'Hello World!'
}
}
```
**JWT**
By default, all another routes will be checked using JWT strategy guard.
It means, you need to pass your access token into the request header.
If you set the transfer method to `both` or `cookie`, you don't need to do anything. The `AccessToken` and `RefreshToken` already be sent via cookie.
If you set the transfer method to `bearer`, you need to pass your access token to the `Authorization` header.
```
Authorization: Bearer {your_access_token}
Refesh-Token: {your_refresh_token}
```
` ()` This decorator will return the current logged-in user.
```typescript
import { Controller, Get } from '@nestjs/common'
import { ApiOperation, ApiTags } from '@nestjs/swagger'
import { IAuthUserEntityForResponse, CurrentUser } from '@mercury-labs/auth'
('User details')
({ path: 'users/-' })
export class ProfileController {
({
summary: 'Get profile',
})
('profile')
public profile(
() user: IAuthUserEntityForResponse
): IAuthUserEntityForResponse {
return user
}
}
```
## Triggered Events
### `UserLoggedInEvent`
Triggered when user logged in successfully.
Sample usages
```typescript
import { UserLoggedInEvent } from '@mercury-labs/auth'
import { EventsHandler, IEventHandler } from '@nestjs/cqrs'
import { delay, lastValueFrom, of, tap } from 'rxjs'
(UserLoggedInEvent)
export class UserLoggedInEventHandler implements IEventHandler<UserLoggedInEvent> {
public async handle(event: UserLoggedInEvent): Promise<void> {
await lastValueFrom(
of(event).pipe(
delay(1200),
tap(({ user, isImpersonated }) => {
console.log('UserLoggedInEvent', { user, isImpersonated })
})
)
)
}
}
```
#### Notes:
- You must install package [/cqrs](https://www.npmjs.com/package/@nestjs/cqrs) to work with auth events.
## Next plan
I will implement some famous oauth methods
- Login using google/facebook/github...
- Allow user to revoke `accessToken`, `refreshToken` of some user.
- E2E tests, more tests...