Skip to content

Commit

Permalink
Implementing OAuth2 and pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
potitoaghilar committed May 8, 2021
1 parent 8446160 commit c8bb83f
Show file tree
Hide file tree
Showing 18 changed files with 207 additions and 164 deletions.
3 changes: 3 additions & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ OAUTH2_CLIENT_ID=<client-id>
OAUTH2_CLIENT_SECRET=<client-secret>
JWT_SECRET=<jwt-secret>
NODE_TLS_REJECT_UNAUTHORIZED='0'

# OAuth secret key
SECRETS_SYSTEM=<generate-new-key>
3 changes: 3 additions & 0 deletions .env.prod
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ DATABASE_URL=mysql://<user>:<password>@<host>:<port>/<database>
OAUTH2_CLIENT_ID=<client-id>
OAUTH2_CLIENT_SECRET=<client-secret>
JWT_SECRET=<jwt-secret>

# OAuth secret key
SECRETS_SYSTEM=<generate-new-key>
78 changes: 14 additions & 64 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,86 +1,36 @@
## REST API Node Boilerplate

### Databases initialization
### Base services setup

We have 2 databases:
- `mysql` database for our main app
- `postgresql` database for authorization server
> Warning: for production ready environments change this part as you prefer. This is a simple example, setting up a strong OAuth2 authorization server. Advanced configurations are out of the scope of this guide
Start `mysql` database with:
```bash
$ docker-compose up -d
```
Services spawned in this demo are:
- `mysql` database
- `postgresql` database
- `hydra-migrate`

Stop `mysql` database with:
Generate OAuth2 server secret:
```bash
$ docker-compose down
```

### OAuth2 authorization server (POC guide)

> Warning: for production ready environments change this part as you prefer. This is a simple example, setting up a strong OAuth2 authorization server. Advanced configurations are out of the scope of this guide
Firstly create a docker network:
```bash
$ docker network create hydranet
```

In order to deploy OAuth2 server deploy a PostgreSQL:
```bash
$ docker run \
--network hydranet \
--name hydra-postgres \
-e POSTGRES_USER=hydra \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=hydra \
-d postgres:9.6
$ export LC_CTYPE=C; cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
```

set environmental variables:
```bash
$ export SECRETS_SYSTEM=$(export LC_CTYPE=C; cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
$ export DSN=postgres://hydra:secret@hydra-postgres:5432/hydra?sslmode=disable
```
Save it in `.env*`

> Store SECRETS_SYSTEM otherwise you will lose access to authorization server
See all environmental variables supported by Hydra:
Start all services with:
```bash
$ docker run -it --rm --entrypoint hydra oryd/hydra:v1.10.2 help serve
```

Start database migrations:
```bash
$ docker run -it --rm \
--network hydranet \
oryd/hydra:v1.10.2 \
migrate sql --yes $DSN
```

Run server:
```bash
$ docker run -d \
--name hydra-server \
--network hydranet \
-p 4444:4444 \
-p 4445:4445 \
-e SECRETS_SYSTEM=$SECRETS_SYSTEM \
-e DSN=$DSN \
-e URLS_SELF_ISSUER=http://localhost:4444/ \
-e URLS_CONSENT=http://localhost:9020/consent \
-e URLS_LOGIN=http://localhost:9020/login \
oryd/hydra:v1.10.2 serve all \
--dangerous-force-http
$ docker-compose up -d
```

Check if it is running:
Stop all services with:
```bash
$ docker logs hydra-server
$ docker-compose down
```

Create your first client:
```bash
$ docker exec hydra-server \
$ docker exec hydra \
hydra clients create \
--endpoint http://127.0.0.1:4445/ \
--id KoxHwD6t027ZyPKoeRZuICc3BQO3xP1d \
Expand Down
67 changes: 57 additions & 10 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ services:

mysql:
image: mysql:8
container_name: mysql
environment:
MYSQL_DATABASE: 'db'
MYSQL_USER: 'user'
Expand All @@ -16,17 +17,63 @@ services:
volumes:
- mysql-data:/var/lib/mysql

#postgres:
# image: postgres:9.6
# environment:
# POSTGRES_DB: 'hydra'
# POSTGRES_USER: 'hydra'
# POSTGRES_PASSWORD: 'secret'
# ports:
# - 5432:5432
# networks:
# - restapi
postgres:
image: postgres:13.2
container_name: postgres
restart: always
environment:
POSTGRES_DB: 'hydra'
POSTGRES_USER: 'hydra'
POSTGRES_PASSWORD: 'secret'
ports:
- 5432:5432
volumes:
- postgresql-data:/var/lib/postgresql/data:delegated

hydra-migrate:
image: oryd/hydra:v1.10.2
container_name: hydra-migrate
environment:
- DSN=postgres://hydra:secret@postgres:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
command: migrate sql -e --yes
restart: on-failure
depends_on:
- postgres

hydra:
image: oryd/hydra:v1.10.2
container_name: hydra
ports:
- 4444:4444 # Public port
- 4445:4445 # Admin port
command: serve all --dangerous-force-http
depends_on:
- hydra-migrate
environment:
- URLS_SELF_ISSUER=http://127.0.0.1:4444/
- URLS_SELF_PUBLIC=http://hydra:4444/
- URLS_CONSENT=http://127.0.0.1:9020/consent
- URLS_LOGIN=http://127.0.0.1:9020/login
- URLS_LOGOUT=http://127.0.0.1:9020/logout
- DSN=postgres://hydra:secret@postgres:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
- SECRETS_SYSTEM=${SECRETS_SYSTEM}
- TTL_ACCESS_TOKEN=30s # 1 hour before expiration
- TTL_REFRESH_TOKEN=720h # 30 days before expiration
- OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise
- OIDC_SUBJECT_IDENTIFIERS_PAIRWISE_SALT=youReallyNeedToChangeThis
- SERVE_COOKIES_SAME_SITE_MODE=Lax
- LOG_LEAK_SENSITIVE_VALUES=true
- LOG_LEVEL=debug
restart: unless-stopped

frontend:
image: oryd/hydra-login-consent-node:v1.3.2
container_name: frontend
environment:
HYDRA_ADMIN_URL: http://hydra:4445
ports:
- 9020:3000

volumes:
mysql-data:
postgresql-data:
24 changes: 14 additions & 10 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,28 @@ datasource db {
url = env("DATABASE_URL")
}

model State {
state String
ip String
model StateIpBind {
state String
ip String
createdAt DateTime @default(now())
@@id([state, ip])
}

model Token {
id Int @id @default(autoincrement())
userId String
user User @relation(fields: [userId], references: [id])
accessToken String
refreshToken String
expirationDate DateTime
id Int @id @default(autoincrement())
userId String
user User @relation(fields: [userId], references: [id])
accessToken String @unique
refreshToken String @unique
accessTokenExpirationDate DateTime
refreshTokenExpirationDate DateTime?
rawData Json
@@index([accessToken, refreshToken])
}

model User {
// id Int @id @default(autoincrement())
id String @id @default(uuid())
email String @unique
firstName String
Expand Down
65 changes: 26 additions & 39 deletions src/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import Method = Lifecycle.Method
const jwt2 = require('hapi-auth-jwt2')
import boom from '@hapi/boom'
import jwt from "jsonwebtoken";
import ClientOAuth2 from "client-oauth2";
import TokenRepository from "../repositories/core/oauth2/token-repository";

export default async function registerBearerTokenStrategy(server: Hapi.Server) {

Expand All @@ -23,82 +25,68 @@ export default async function registerBearerTokenStrategy(server: Hapi.Server) {
* Validate token with OAuth2 introspection endpoint
*/

const oauth2Provider = await Oauth2Provider.getProviderInstance()
const oauth2Provider = await Oauth2Provider.getInstance()
const oauth2Client = await Oauth2Provider.getClient()
const introspectionEndpoint = oauth2Provider.getOpenidConfig().introspection_endpoint
const userId = Profile.getUserId(tokenData.user)

// Check if introspection endpoint is set
if (!introspectionEndpoint) {
console.error('ERROR: OAuth2 introspection endpoint not set')
return { isValid: false }
}

// TODO on token update dies here
// Check if access token is in database
const userTokenBind = TokenUserBind.fromJSON<TokenUserBind>(
await PrismaProvider.getInstance().token.findFirst({
where: {
userId: Profile.getUserId(tokenData.user),
accessToken: tokenData.accessToken
}
})
)
const userTokenBind = await TokenRepository.getTokenUserBind(userId, tokenData.accessToken)
if (!userTokenBind) {
return { isValid: false, response: boom.unauthorized }
return { isValid: false }
}

// TODO resolve bad request here
// Validate access token with authorization server
const accessToken = tokenData.accessToken
const body = JSON.stringify({ token: accessToken })
const validationResponse = await fetch(introspectionEndpoint, {
const validationResponse = await (await fetch(introspectionEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': body.length.toString()
},
})
console.log(validationResponse)
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `token=${accessToken}`
})).json()

// If access token is expired try to refresh it
if (!validationResponse.ok) {
if (!validationResponse.active) {

// Get refresh token from database
const refreshToken = userTokenBind.refreshToken

const oauthToken = (await Oauth2Provider.getInstance()).createToken(accessToken, refreshToken, 'bearer', { })
// Generate token from rawData
const oauthToken = oauth2Client.createToken(userTokenBind.rawData as ClientOAuth2.Data)

try {

// Try to refresh access code
const refreshedToken = await oauthToken.refresh()
const refreshedOauthToken = await oauthToken.refresh()

// Update new token to database
await PrismaProvider.getInstance().token.update({
where: { refreshToken },
data: {
accessToken: refreshedToken.accessToken
}
})
// Update new tokens to database
await TokenRepository.updateTokenUserBind(userId, oauthToken, refreshedOauthToken)

// Exit if JWT secret is not set
if (!process.env.JWT_SECRET) {
console.error('ERROR: JWT secret not set')
return { isValid: false, response: boom.badImplementation() }
return { isValid: false }
}

// Generate JWT token
const jwtToken = jwt.sign({
user: tokenData.user,
accessToken: refreshedToken.accessToken
accessToken: refreshedOauthToken.accessToken
}, process.env.JWT_SECRET)

// TODO remove
console.log(jwtToken)

// Notify client to change access token for next requests
return { isValid: true, credentials: { token: jwtToken } }

} catch (ex) {

// Remove refresh token from database
await PrismaProvider.getInstance().token.delete({
where: { refreshToken }
})
await TokenRepository.removeTokenUserBind(oauthToken)

// If refresh token is expired, force authenticate again
return { isValid: false }
Expand All @@ -107,8 +95,7 @@ export default async function registerBearerTokenStrategy(server: Hapi.Server) {

}

const isValid = (await validationResponse.json()).active
return { isValid };
return { isValid: validationResponse.active };
},
})

Expand Down
8 changes: 7 additions & 1 deletion src/models/core/oauth2/token-user-bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export default class TokenUserBind extends BaseModel {
refreshToken!: string

@date().required()
expirationDate!: Date
accessTokenExpirationDate!: Date

@date().optional()
refreshTokenExpirationDate!: Date | null

@object().required()
rawData!: object

}
5 changes: 4 additions & 1 deletion src/models/core/shared/base-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ class BaseModel {
return (joiful.getSchema(this) as Joi.ObjectSchema).label(this.name.toLowerCase())
}

public static fromJSON<T>(json: object): T {
public static fromJSON<T>(json: object | null): T | null {
if (!json) {
return null
}
const obj = Object.assign(new this(), json)
Object.keys(obj).forEach(key => {
const result = internalModelMapping.find(x => x.className == this.name && x.fieldName == key)
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/core/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const healthController: Hapi.Plugin<undefined> = {
path: '/health',
handler: (_, h: ResponseToolkit) => {
return h.response({ status: 'HEALTHY' }).code(200)
},
options: {
auth: false
}
})
}
Expand Down
Loading

0 comments on commit c8bb83f

Please sign in to comment.