Skip to content

Commit

Permalink
feat(auth): httpsig request validation (#387)
Browse files Browse the repository at this point in the history
  • Loading branch information
njlie committed Aug 25, 2022
1 parent 8309d88 commit 872c877
Show file tree
Hide file tree
Showing 20 changed files with 1,111 additions and 284 deletions.
Expand Up @@ -2,7 +2,7 @@ exports.up = function (knex) {
return knex.schema.createTable('accessTokens', function (table) {
table.uuid('id').notNullable().primary()
table.string('value').notNullable().unique()
table.string('managementId').notNullable()
table.uuid('managementId').notNullable().unique()
table.integer('expiresIn').notNullable()
table.uuid('grantId').notNullable()
table.foreign('grantId').references('grants.id').onDelete('CASCADE')
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/package.json
Expand Up @@ -45,6 +45,6 @@
"jest-openapi": "^0.14.2",
"nock": "^13.2.4",
"openapi-types": "^12.0.0",
"typescript": "^4.2.4"
"typescript": "^4.3.0"
}
}
166 changes: 95 additions & 71 deletions packages/auth/src/accessToken/routes.test.ts
Expand Up @@ -16,27 +16,11 @@ import { AccessToken } from './model'
import { Access } from '../access/model'
import { AccessTokenRoutes } from './routes'
import { createContext } from '../tests/context'

const KEY_REGISTRY_ORIGIN = 'https://openpayments.network'
const TEST_KID_PATH = '/keys/test-key'
const TEST_JWK = {
kid: KEY_REGISTRY_ORIGIN + TEST_KID_PATH,
x: 'test-public-key',
kty: 'OKP',
alg: 'EdDSA',
crv: 'Ed25519',
use: 'sig'
}
const TEST_CLIENT_KEY = {
client: {
id: v4(),
name: 'Bob',
email: 'bob@bob.com',
image: 'a link to an image',
uri: 'https://bob.com'
},
...TEST_JWK
}
import {
KID_PATH,
KEY_REGISTRY_ORIGIN,
TEST_CLIENT_KEY
} from '../grant/routes.test'

describe('Access Token Routes', (): void => {
let deps: IocContract<AppServices>
Expand Down Expand Up @@ -71,7 +55,7 @@ describe('Access Token Routes', (): void => {
finishMethod: FinishMethod.Redirect,
finishUri: 'https://example.com/finish',
clientNonce: crypto.randomBytes(8).toString('hex').toUpperCase(),
clientKeyId: KEY_REGISTRY_ORIGIN + TEST_KID_PATH,
clientKeyId: KEY_REGISTRY_ORIGIN + KID_PATH,
interactId: v4(),
interactRef: crypto.randomBytes(8).toString('hex').toUpperCase(),
interactNonce: crypto.randomBytes(8).toString('hex').toUpperCase()
Expand All @@ -92,14 +76,18 @@ describe('Access Token Routes', (): void => {

const BASE_TOKEN = {
value: crypto.randomBytes(8).toString('hex').toUpperCase(),
managementId: 'https://example.com/manage/12345',
managementId: v4(),
expiresIn: 3600
}

describe('Introspect', (): void => {
let grant: Grant
let access: Access
let token: AccessToken

const url = '/introspect'
const method = 'POST'

beforeEach(async (): Promise<void> => {
grant = await Grant.query(trx).insertAndFetch({
...BASE_GRANT
Expand All @@ -116,61 +104,48 @@ describe('Access Token Routes', (): void => {
test('Cannot introspect fake token', async (): Promise<void> => {
const ctx = createContext(
{
headers: { Accept: 'application/json' },
url: '/introspect',
method: 'POST'
headers: {
Accept: 'application/json'
},
url,
method
},
{}
)
ctx.request.body = {
access_token: v4(),
proof: 'httpsig',
resource_server: 'test'
}
await expect(accessTokenRoutes.introspect(ctx)).rejects.toMatchObject({
status: 404,
await expect(accessTokenRoutes.introspect(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(404)
expect(ctx.body).toMatchObject({
error: 'invalid_request',
message: 'token not found'
})
})

test('Cannot introspect if no token passed', async (): Promise<void> => {
const ctx = createContext(
{
headers: { Accept: 'application/json' },
url: '/introspect',
method: 'POST'
},
{}
)
ctx.request.body = {
proof: 'httpsig',
resource_server: 'test'
}
await expect(accessTokenRoutes.introspect(ctx)).rejects.toMatchObject({
status: 400,
message: 'invalid introspection request'
})
})

test('Successfully introspects valid token', async (): Promise<void> => {
const clientId = crypto
.createHash('sha256')
.update(TEST_CLIENT_KEY.client.id)
.update(TEST_CLIENT_KEY.jwk.client.id)
.digest('hex')
const scope = nock(KEY_REGISTRY_ORIGIN)
.get(TEST_KID_PATH)
.reply(200, TEST_CLIENT_KEY)
.get(KID_PATH)
.reply(200, TEST_CLIENT_KEY.jwk)

const ctx = createContext(
{
headers: { Accept: 'application/json' },
headers: {
Accept: 'application/json'
},
url: '/introspect',
method: 'POST'
},
{}
)

ctx.request.body = {
access_token: token.value,
proof: 'httpsig',
resource_server: 'test'
}
await expect(accessTokenRoutes.introspect(ctx)).resolves.toBeUndefined()
Expand All @@ -179,6 +154,9 @@ describe('Access Token Routes', (): void => {
expect(ctx.response.get('Content-Type')).toBe(
'application/json; charset=utf-8'
)

const testKeyWithoutClient = TEST_CLIENT_KEY.jwk
delete testKeyWithoutClient.client
expect(ctx.body).toEqual({
active: true,
grant: grant.id,
Expand All @@ -189,27 +167,41 @@ describe('Access Token Routes', (): void => {
limits: access.limits
}
],
key: { proof: 'httpsig', jwk: TEST_JWK },
key: {
proof: 'httpsig',
jwk: {
...testKeyWithoutClient,
exp: expect.any(Number),
nbf: expect.any(Number),
revoked: false
}
},
client_id: clientId
})
scope.isDone()
})

test('Successfully introspects expired token', async (): Promise<void> => {
const scope = nock(KEY_REGISTRY_ORIGIN)
.get(KID_PATH)
.reply(200, TEST_CLIENT_KEY.jwk)
const now = new Date(new Date().getTime() + 4000)
jest.useFakeTimers()
jest.setSystemTime(now)

const ctx = createContext(
{
headers: { Accept: 'application/json' },
headers: {
Accept: 'application/json'
},
url: '/introspect',
method: 'POST'
},
{}
)

ctx.request.body = {
access_token: token.value,
proof: 'httpsig',
resource_server: 'test'
}
await expect(accessTokenRoutes.introspect(ctx)).resolves.toBeUndefined()
Expand All @@ -221,13 +213,18 @@ describe('Access Token Routes', (): void => {
expect(ctx.body).toEqual({
active: false
})

scope.isDone()
})
})

describe('Revocation', (): void => {
let grant: Grant
let token: AccessToken
let id: string
let managementId: string
let url: string

const method = 'DELETE'

beforeEach(async (): Promise<void> => {
grant = await Grant.query(trx).insertAndFetch({
Expand All @@ -237,52 +234,79 @@ describe('Access Token Routes', (): void => {
grantId: grant.id,
...BASE_TOKEN
})
id = token.id
managementId = token.managementId
url = `/token/${managementId}`
})

test('Returns status 204 even if token does not exist', async (): Promise<void> => {
id = v4()
managementId = v4()
const ctx = createContext(
{
headers: { Accept: 'application/json' },
url: `/token/${id}`,
method: 'DELETE'
headers: {
Accept: 'application/json'
},
url: `/token/${managementId}`,
method
},
{ id }
{ id: managementId }
)

await accessTokenRoutes.revoke(ctx)
expect(ctx.response.status).toBe(204)
})

test('Returns status 204 if token has not expired', async (): Promise<void> => {
const scope = nock(KEY_REGISTRY_ORIGIN)
.get(KID_PATH)
.reply(200, TEST_CLIENT_KEY.jwk)

const ctx = createContext(
{
headers: { Accept: 'application/json' },
url: `/token/${id}`,
method: 'DELETE'
headers: {
Accept: 'application/json'
},
url,
method
},
{ id }
{ id: managementId }
)

ctx.request.body = {
access_token: token.value,
proof: 'httpsig',
resource_server: 'test'
}
await token.$query(trx).patch({ expiresIn: 10000 })
await accessTokenRoutes.revoke(ctx)
expect(ctx.response.status).toBe(204)
scope.isDone()
})

test('Returns status 204 if token has expired', async (): Promise<void> => {
const scope = nock(KEY_REGISTRY_ORIGIN)
.get(KID_PATH)
.reply(200, TEST_CLIENT_KEY.jwk)

const ctx = createContext(
{
headers: { Accept: 'application/json' },
url: `/token/${id}`,
method: 'DELETE'
headers: {
Accept: 'application/json'
},
url,
method
},
{ id }
{ id: managementId }
)

ctx.request.body = {
access_token: token.value,
proof: 'httpsig',
resource_server: 'test'
}
await token.$query(trx).patch({ expiresIn: -1 })
await accessTokenRoutes.revoke(ctx)
expect(ctx.response.status).toBe(204)
scope.isDone()
})
})

Expand Down
29 changes: 14 additions & 15 deletions packages/auth/src/accessToken/routes.ts
Expand Up @@ -4,11 +4,13 @@ import { AppContext } from '../app'
import { IAppConfig } from '../config/app'
import { AccessTokenService, Introspection } from './service'
import { accessToBody } from '../shared/utils'
import { ClientService } from '../client/service'

interface ServiceDependencies {
config: IAppConfig
logger: Logger
accessTokenService: AccessTokenService
clientService: ClientService
}

export interface AccessTokenRoutes {
Expand All @@ -35,19 +37,19 @@ async function introspectToken(
deps: ServiceDependencies,
ctx: AppContext
): Promise<void> {
// TODO: request validation
const { body } = ctx.request
if (body['access_token']) {
const introspectionResult = await deps.accessTokenService.introspect(
body['access_token']
)
if (introspectionResult) {
ctx.body = introspectionToBody(introspectionResult)
} else {
return ctx.throw(404, 'token not found')
}
const introspectionResult = await deps.accessTokenService.introspect(
body['access_token']
)
if (introspectionResult) {
ctx.body = introspectionToBody(introspectionResult)
} else {
return ctx.throw(400, 'invalid introspection request')
ctx.status = 404
ctx.body = {
error: 'invalid_request',
message: 'token not found'
}
return
}
}

Expand All @@ -68,8 +70,6 @@ async function revokeToken(
deps: ServiceDependencies,
ctx: AppContext
): Promise<void> {
//TODO: verify accessToken with httpsig method

const { id: managementId } = ctx.params
await deps.accessTokenService.revoke(managementId)
ctx.status = 204
Expand All @@ -79,7 +79,6 @@ async function rotateToken(
deps: ServiceDependencies,
ctx: AppContext
): Promise<void> {
//TODO: verify accessToken with httpsig method
const { id: managementId } = ctx.params
const result = await deps.accessTokenService.rotate(managementId)
if (result.success == true) {
Expand All @@ -88,7 +87,7 @@ async function rotateToken(
access_token: {
access: result.access.map((a) => accessToBody(a)),
value: result.value,
manage: result.managementId,
manage: deps.config.authServerDomain + `/token/${result.managementId}`,
expires_in: result.expiresIn
}
}
Expand Down

0 comments on commit 872c877

Please sign in to comment.