Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): httpsig request validation #387

Merged
merged 33 commits into from Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b755923
chore(*): add yarn cache to gitignore
njlie Aug 16, 2022
8cdb650
feat(auth): httpsig request validation
njlie Jun 27, 2022
98fe6bd
feat: tests
njlie Jun 29, 2022
0755f7c
chore: remove packages
njlie Jun 29, 2022
0700ff4
fix: bump jest to fix tests
njlie Jul 1, 2022
d3f7c7c
fix: tests
njlie Jul 1, 2022
b6c4b69
fix: fix backend tests
njlie Jul 5, 2022
6189b27
chore: add extract-files explicitly
njlie Jul 6, 2022
ff45b69
chore: try bumping graphql-tools
njlie Jul 6, 2022
9821292
fix: tests
njlie Jul 13, 2022
66d8942
feat: httpsig middleware
njlie Jul 26, 2022
eff5c1f
fix: tests
njlie Aug 1, 2022
d15a329
fix: rebase conflicts
njlie Aug 1, 2022
06927d7
feat: review comments
njlie Aug 4, 2022
b65208a
chore: bump typescript
njlie Aug 4, 2022
ec80231
feat: middleware on /continue endpoint
njlie Aug 12, 2022
7518f22
chore: bump typescript at project root
njlie Aug 15, 2022
7e5b5c0
chore: rollback typescript bump at root
njlie Aug 15, 2022
ea3c56a
feat: remove comments, cleanup TODO, rely on openapi for validation
njlie Aug 16, 2022
5db1bd1
feat: clean up tests
njlie Aug 18, 2022
c7d6fa0
fix: build error
njlie Aug 18, 2022
dffed48
feat: remove .yarn
njlie Aug 18, 2022
b702055
chore: remove unnecessary calls from tests
njlie Aug 18, 2022
94d2950
fix: convert managementId to uuid
njlie Aug 19, 2022
91b41d2
chore: remove yarn.lock
njlie Aug 22, 2022
17d445d
chore: prettier
njlie Aug 22, 2022
d3909e2
chore: rename variables that were breaking tests for some reason
njlie Aug 22, 2022
d0ab02b
fix: build errors
njlie Aug 23, 2022
2c318ed
feat: dynamically generate keys for tests
njlie Aug 24, 2022
c65ccc0
chore: clean up unused deps, fix build errors
njlie Aug 24, 2022
8b6a1b5
fix: typos, consistent current date
njlie Aug 24, 2022
6516c7c
feat: abstract direct model queries, create signature service
njlie Aug 24, 2022
1e3d0a8
feat: fix issues from rebase
njlie Aug 24, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still getting the warning and so is GitHub Actions
#387 (comment)
https://github.com/interledger/rafiki/runs/7682195245?check_suite_focus=true

I wonder if typescript should be updated in the root package.json

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try it and see what happens

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumping typescript breaks a lot of stuff

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't make any sense because ^4.2.4 actually installs 4.7.2 currently as the caret means any non breaking changes.

}
}
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(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that not have a path? Or is that already the parsed access_token from the introspection response?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is supposed to represent a row in the accessTokens table, I initially (i.e. the early stages of committing my GNAP notes to code) thought to put an entire URL in as a field on the access token but quickly realized that did not make sense. Now it just holds a path parameter that is inserted into the manage URL returned by grant continuation.

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