Skip to content
This repository has been archived by the owner on Nov 7, 2023. It is now read-only.

express-did-auth - Implement personal_sign user signature #72

Merged
merged 9 commits into from
Nov 25, 2020
273 changes: 267 additions & 6 deletions packages/express-did-auth/package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions packages/express-did-auth/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{
"name": "@rsksmart/express-did-auth",
"version": "0.1.0",
"version": "0.1.1",
"description": "Authentication with Verifiable Credentials for Express.js",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": ["lib/"],
"files": [
"lib/"
],
"scripts": {
"build": "tsc"
},
Expand Down Expand Up @@ -35,6 +37,7 @@
"did-jwt": "^4.6.2",
"did-jwt-vc": "^1.0.6",
"did-resolver": "^2.1.1",
"ethereumjs-util": "^7.0.7",
"ethr-did-resolver": "^3.0.0",
"express": "^4.17.1",
"js-sha3": "^0.8.0"
Expand Down
3 changes: 1 addition & 2 deletions packages/express-did-auth/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export const UNKNOWN_ERROR = 'UNKNOWN_ERROR'
export const INVALID_DID = 'INVALID_DID'
export const INVALID_CHALLENGE = 'INVALID_CHALLENGE'
export const CORRUPTED_CHALLENGE = 'CORRUPTED_CHALLENGE'
export const INVALID_CHALLENGE_RESPONSE = 'INVALID_CHALLENGE_RESPONSE'
export const MAX_REQUESTS_REACHED = 'MAX_REQUESTS_REACHED'
export const EXPIRED_ACCESS_TOKEN = 'EXPIRED_ACCESS_TOKEN'
export const NO_ACCESS_TOKEN = 'NO_ACCESS_TOKEN'
Expand Down
37 changes: 21 additions & 16 deletions packages/express-did-auth/src/factories/authentication-factory.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { ecrecover, fromRpcSig, hashPersonalMessage, pubToAddress } from 'ethereumjs-util'
import { ACCESS_TOKEN_COOKIE_NAME, COOKIES_ATTRIBUTES, REFRESH_TOKEN_COOKIE_NAME } from '../constants'
import { CORRUPTED_CHALLENGE, INVALID_CHALLENGE, NO_RESPONSE, UNAUTHORIZED_USER } from '../errors'
import {
AuthenticationBusinessLogic, SignupBusinessLogic,
ChallengeResponsePayload, AppState, AuthenticationConfig
} from '../types'
import { generateAccessToken, verifyReceivedJwt } from '../jwt-utils'
import { INVALID_CHALLENGE_RESPONSE, NO_RESPONSE, UNAUTHORIZED_USER } from '../errors'
import { AuthenticationBusinessLogic, SignupBusinessLogic, AppState, AuthenticationConfig } from '../types'
import { generateAccessToken } from '../jwt-utils'
import { ChallengeVerifier } from '../classes/challenge-verifier'
import { SessionManagerFactory } from '../classes/session-manager'
import { RequestCounterFactory } from '../classes/request-counter'
Expand All @@ -23,27 +21,34 @@ export function authenticationFactory (

if (!response) return res.status(401).send(NO_RESPONSE)

const { payload } = await verifyReceivedJwt(response, config)
const { iss, challenge, sub } = payload as ChallengeResponsePayload
const { sig, did } = response

if (sub !== config.serviceDid) return res.status(401).send(CORRUPTED_CHALLENGE)
const expectedMessage = `Login to ${config.serviceUrl}\nVerification code: ${challengeVerifier.get(did)}`

if (!challengeVerifier.verify(iss!, challenge)) {
return res.status(401).send(INVALID_CHALLENGE)
}
const messageDigest = hashPersonalMessage(Buffer.from(expectedMessage))
const ecdsaSignature = fromRpcSig(sig)

const isValid = businessLogic ? await businessLogic(payload) : true
const address = '0x' + pubToAddress(ecrecover(
messageDigest,
ecdsaSignature.v,
ecdsaSignature.r,
ecdsaSignature.s
)).toString('hex')

if (address !== did.split(':').pop().toLowerCase()) return res.status(401).send(INVALID_CHALLENGE_RESPONSE)
ilanolkies marked this conversation as resolved.
Show resolved Hide resolved

const isValid = businessLogic ? await businessLogic(null) : true

if (!isValid) return res.status(401).send(UNAUTHORIZED_USER)

const requestCounter = requestCounterFactory()
const sessionManager = sessionManagerFactory()

const accessToken = await generateAccessToken(iss, config)
const accessToken = await generateAccessToken(did, config)
const refreshToken = sessionManager.createRefreshToken()

state.sessions[iss] = { requestCounter, sessionManager }
state.refreshTokens[refreshToken] = iss
state.sessions[did] = { requestCounter, sessionManager }
state.refreshTokens[refreshToken] = did

if (!config.useCookies) return res.status(200).json({ accessToken, refreshToken })

Expand Down
74 changes: 53 additions & 21 deletions packages/express-did-auth/test/authentication-factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { authenticationFactory } from '../src/factories/authentication-factory'
import { ChallengeVerifier } from '../src/classes/challenge-verifier'
import {
challengeResponseFactory, getMockedAppState, Identity, identityFactory, mockedResFactory,
identityFactory, challengeResponseFactory, getMockedAppState, Identity, mockedResFactory,
MockedResponse, modulo0Timestamp, otherSlotTimestamp
} from './utils'
import MockDate from 'mockdate'
import { CORRUPTED_CHALLENGE, INVALID_CHALLENGE, NO_RESPONSE, UNAUTHORIZED_USER } from '../src/errors'
import { INVALID_CHALLENGE_RESPONSE, NO_RESPONSE, UNAUTHORIZED_USER } from '../src/errors'
import { AppState, AuthenticationBusinessLogic, SignupBusinessLogic, TokenConfig } from '../src/types'
import { RequestCounter, RequestCounterConfig, RequestCounterFactory } from '../src/classes/request-counter'
import { SessionManager, SessionManagerFactory, UserSessionConfig } from '../src/classes/session-manager'

describe('authenticationFactory', () => {
let config: TokenConfig
let userIdentity: Identity
let userPrivateKey: string
let state: AppState

const mockBusinessLogicFactory = (result: boolean) => async () => result
Expand All @@ -28,14 +29,16 @@ describe('authenticationFactory', () => {
)

beforeAll(async () => {
const serviceIdentity = await identityFactory()
const serviceIdentity = await identityFactory().identity
config = {
serviceDid: serviceIdentity.did,
serviceSigner: serviceIdentity.signer,
serviceUrl: 'https://the.service.com'
}

userIdentity = await identityFactory()
const { identity, privateKey } = identityFactory()
userIdentity = identity
userPrivateKey = privateKey
})

beforeEach(() => {
Expand All @@ -51,71 +54,100 @@ describe('authenticationFactory', () => {
await testAuthFactory(state)(req, res)
})

test('should respond with 401 if the subject of the challenge response is not the service did', async () => {
test('should respond with 401 if the signed message contains another service url', async () => {
MockDate.set(modulo0Timestamp)

const challenge = challengeVerifier.get(userIdentity.did)
const anotherIdentity = await identityFactory()

const challengeResponseJwt = await challengeResponseFactory(challenge, userIdentity, anotherIdentity.did, config.serviceUrl)
const challengeResponseJwt = challengeResponseFactory(challenge, userIdentity, userPrivateKey, 'https://taringa.net')

const req = { body: { response: challengeResponseJwt } }
const res = mockedResFactory(401, CORRUPTED_CHALLENGE)
const res = mockedResFactory(401, INVALID_CHALLENGE_RESPONSE)

const logic = mockBusinessLogicFactory(false)
await testAuthFactory(state, logic)(req, res)
})

test('should respond with 401 if extra business logic that returns false', async () => {
test('should respond with 401 if the message is signed with another did', async () => {
MockDate.set(modulo0Timestamp)

const challenge = challengeVerifier.get(userIdentity.did)
const challengeResponseJwt = await challengeResponseFactory(challenge, userIdentity, config.serviceDid, config.serviceUrl)
const anotherIdentity = await identityFactory()

const challengeResponseJwt = challengeResponseFactory(challenge, anotherIdentity.identity, anotherIdentity.privateKey, config.serviceUrl)

const req = { body: { response: challengeResponseJwt } }
const res = mockedResFactory(401, UNAUTHORIZED_USER)
const res = mockedResFactory(401, INVALID_CHALLENGE_RESPONSE)

const logic = mockBusinessLogicFactory(false)
await testAuthFactory(state, logic)(req, res)
})

test('should respond with 401 if extra business logic that throws an error', async () => {
test('should respond with 401 if the signer of the message is not the specified did', async () => {
MockDate.set(modulo0Timestamp)

const challenge = challengeVerifier.get(userIdentity.did)
const challengeResponseJwt = await challengeResponseFactory(challenge, userIdentity, config.serviceDid, config.serviceUrl)
const anotherIdentity = await identityFactory()

const challengeResponseJwt = challengeResponseFactory(challenge, anotherIdentity.identity, anotherIdentity.privateKey, 'https://taringa.net')
challengeResponseJwt.did = userIdentity.did

const errorMessage = 'This is an error'
const req = { body: { response: challengeResponseJwt } }
const res = mockedResFactory(401, escape(errorMessage))
const res = mockedResFactory(401, INVALID_CHALLENGE_RESPONSE)

const logic = () => { throw new Error(errorMessage) }
const logic = mockBusinessLogicFactory(false)
await testAuthFactory(state, logic)(req, res)
})

test('should respond with 401 if invalid challenge', async () => {
MockDate.set(modulo0Timestamp)

const challenge = challengeVerifier.get(userIdentity.did)
const challengeResponseJwt = await challengeResponseFactory(challenge, userIdentity, config.serviceDid, config.serviceUrl)
const challengeResponseJwt = challengeResponseFactory('a challenge', userIdentity, userPrivateKey, config.serviceUrl)

const req = { body: { response: challengeResponseJwt } }
const res = mockedResFactory(401, INVALID_CHALLENGE)
const res = mockedResFactory(401, INVALID_CHALLENGE_RESPONSE)

MockDate.set(otherSlotTimestamp)
await testAuthFactory(state)(req, res)
})

test('should respond with 401 if extra business logic that returns false', async () => {
MockDate.set(modulo0Timestamp)

const challenge = challengeVerifier.get(userIdentity.did)
const challengeResponseJwt = challengeResponseFactory(challenge, userIdentity, userPrivateKey, config.serviceUrl)

const req = { body: { response: challengeResponseJwt } }
const res = mockedResFactory(401, UNAUTHORIZED_USER)

const logic = mockBusinessLogicFactory(false)
await testAuthFactory(state, logic)(req, res)
})

test('should respond with 401 if extra business logic that throws an error', async () => {
MockDate.set(modulo0Timestamp)

const challenge = challengeVerifier.get(userIdentity.did)
const challengeResponseJwt = challengeResponseFactory(challenge, userIdentity, userPrivateKey, config.serviceUrl)

const errorMessage = 'This is an error'
const req = { body: { response: challengeResponseJwt } }
const res = mockedResFactory(401, escape(errorMessage))

const logic = () => { throw new Error(errorMessage) }
await testAuthFactory(state, logic)(req, res)
})

describe('no cookies', () => {
let req, res

beforeEach(async () => {
MockDate.set(modulo0Timestamp)

const challenge = challengeVerifier.get(userIdentity.did)
const challengeResponseJwt = await challengeResponseFactory(challenge, userIdentity, config.serviceDid, config.serviceUrl)

req = { body: { response: challengeResponseJwt } }
const response = challengeResponseFactory(challenge, userIdentity, userPrivateKey, config.serviceUrl)
req = { body: { response } }

const expectedAssertion = (response: MockedResponse) => {
// eslint-disable-next-line dot-notation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ describe('ExpressMiddlewareFactory', () => {
let userIdentity: Identity

beforeAll(async () => {
const serviceIdentity = await identityFactory()
const serviceIdentity = await identityFactory().identity
config = {
serviceDid: serviceIdentity.did,
serviceSigner: serviceIdentity.signer,
serviceUrl
}

userIdentity = await identityFactory()
userIdentity = await identityFactory().identity
})

test('should respond with 401 if empty header', async () => {
Expand Down Expand Up @@ -52,7 +52,7 @@ describe('ExpressMiddlewareFactory', () => {
})

test('should respond with 401 if the issuer of the access token is not the service did', async () => {
const anotherIdentity = await identityFactory()
const anotherIdentity = await identityFactory().identity
const anotherConfig = {
serviceDid: anotherIdentity.did,
serviceSigner: anotherIdentity.signer,
Expand Down
4 changes: 2 additions & 2 deletions packages/express-did-auth/test/jwt-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ describe('JWT Utils', () => {
const resolver = getDidResolver({})

beforeAll(async () => {
issuerIdentity = await identityFactory()
subjectIdentity = await identityFactory()
issuerIdentity = await identityFactory().identity
subjectIdentity = await identityFactory().identity

config = {
serviceSigner: issuerIdentity.signer,
Expand Down
4 changes: 2 additions & 2 deletions packages/express-did-auth/test/refresh-token-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ describe('RefreshTokenFactory', () => {
let userIdentity: Identity

beforeAll(async () => {
const serviceIdentity = await identityFactory()
const serviceIdentity = await identityFactory().identity
accessTokenConfig = {
serviceDid: serviceIdentity.did,
serviceSigner: serviceIdentity.signer,
serviceUrl
}

userIdentity = await identityFactory()
userIdentity = await identityFactory().identity
})

test('should respond with 401 if no refresh token', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('requestSignupFactory', () => {
const serviceUrl = 'https://the.service.com'

beforeAll(async () => {
const serviceIdentity = await identityFactory()
const serviceIdentity = await identityFactory().identity
serviceDid = serviceIdentity.did
serviceSigner = serviceIdentity.signer
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import MockDate from 'mockdate'
describe.skip('Express app tests (using cookies)', () => {
let userDid: string
let userIdentity: Identity
let userPrivateKey: string
let accessTokenCookie: string
let refreshTokenCookie: string
let oldRefreshTokenCookie: string
Expand All @@ -21,9 +22,12 @@ describe.skip('Express app tests (using cookies)', () => {
const serviceUrl = 'https://service.com'

beforeAll(async () => {
userIdentity = await identityFactory()
const { identity, privateKey } = identityFactory()
userIdentity = identity
userPrivateKey = privateKey
userDid = userIdentity.did
const serviceIdentity = await identityFactory()

const serviceIdentity = identityFactory().identity
const serviceSigner = serviceIdentity.signer
serviceDid = serviceIdentity.did

Expand All @@ -38,7 +42,7 @@ describe.skip('Express app tests (using cookies)', () => {
})

it('2. POST /signup', async () => {
const challengeResponse = await challengeResponseFactory(challenge, userIdentity, serviceDid, serviceUrl)
const challengeResponse = challengeResponseFactory(challenge, userIdentity, userPrivateKey, serviceUrl)
const { header, body } = await cookieAgent.post('/signup').send({ response: challengeResponse }).expect(200)

expect(body).toMatchObject({})
Expand All @@ -55,7 +59,7 @@ describe.skip('Express app tests (using cookies)', () => {
})

it('4. POST /auth', async () => {
const challengeResponse = await challengeResponseFactory(challenge, userIdentity, serviceDid, serviceUrl)
const challengeResponse = challengeResponseFactory(challenge, userIdentity, userPrivateKey, serviceUrl)
const { header, body } = await cookieAgent.post('/auth').send({ response: challengeResponse }).expect(200)

expect(body).toMatchObject({})
Expand Down
9 changes: 6 additions & 3 deletions packages/express-did-auth/test/setup-app.middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ describe('Express app tests', () => {
const app = express()

beforeAll(async () => {
const userIdentity = await identityFactory()
const { identity, privateKey } = identityFactory()
const userIdentity = identity
const userPrivateKey = privateKey
const userDid = userIdentity.did
const serviceIdentity = await identityFactory()

const serviceIdentity = await identityFactory().identity
const serviceSigner = serviceIdentity.signer
const serviceDid = serviceIdentity.did

Expand All @@ -25,7 +28,7 @@ describe('Express app tests', () => {
const requestAuthResponse = await request(app).get(`/request-auth/${userDid}`).expect(200)
const challenge = requestAuthResponse.body.challenge

const challengeResponse = await challengeResponseFactory(challenge, userIdentity, serviceDid, serviceUrl)
const challengeResponse = challengeResponseFactory(challenge, userIdentity, userPrivateKey, serviceUrl)
const authResponse = await request(app).post('/auth').send({ response: challengeResponse }).expect(200)

accessToken = authResponse.body.accessToken
Expand Down
Loading