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(backend): unauthenticated get incoming payment #1952

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions openapi/resource-server.yaml
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 plan on pulling in the latest open-payments spec when we do the open-payments release. Leaving these for now as it is required to run/tests.

Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,9 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/incoming-payment-with-connection'
anyOf:
- $ref: '#/components/schemas/incoming-payment-with-connection'
- $ref: '#/components/schemas/public-incoming-payment'
examples:
Incoming Payment for $25 with $12.34 received so far:
value:
Expand Down Expand Up @@ -647,10 +649,10 @@ paths:
$ref: '#/components/responses/403'
'404':
description: Incoming Payment Not Found
description: A client can fetch the latest state of an incoming payment to determine the amount received into the payment pointer.
parameters:
- $ref: '#/components/parameters/signature-input'
- $ref: '#/components/parameters/signature'
- $ref: '#/components/parameters/optional-signature-input'
- $ref: '#/components/parameters/optional-signature'
description: A client can fetch the latest state of an incoming payment to determine the amount received into the payment pointer.
parameters:
- $ref: '#/components/parameters/id'
'/incoming-payments/{id}/complete':
Expand Down Expand Up @@ -1026,6 +1028,20 @@ components:
description: Endpoint that returns unique STREAM connection credentials to establish a STREAM connection to the underlying account.
readOnly: true
unevaluatedProperties: false
public-incoming-payment:
title: Public Incoming Payment
description: An **incoming payment** resource with public details.
type: object
examples:
- receivedAmount:
value: '0'
assetCode: USD
assetScale: 2
properties:
receiveAmount:
$ref: ./schemas.yaml#/components/schemas/amount
unresolvedProperites: false

outgoing-payment:
title: Outgoing Payment
description: 'An **outgoing payment** resource represents a payment that will be, is currently being, or has previously been, sent from the payment pointer.'
Expand Down Expand Up @@ -1317,17 +1333,29 @@ components:
name: Signature
in: header
schema:
type: string
example: 'Signature: sig1=:EWJgAONk3D6542Scj8g51rYeMHw96cH2XiCMxcyL511wyemGcw==:'
$ref: '#/components/parameters/optional-signature'
description: 'The signature generated based on the Signature-Input, using the signing algorithm specified in the "alg" field of the JWK.'
required: true
signature-input:
name: Signature-Input
in: header
schema:
$ref: '#/components/parameters/optional-signature-input'
description: 'The Signature-Input field is a Dictionary structured field containing the metadata for one or more message signatures generated from components within the HTTP message. Each member describes a single message signature. The member''s key is the label that uniquely identifies the message signature within the context of the HTTP message. The member''s value is the serialization of the covered components Inner List plus all signature metadata parameters identified by the label. The following components MUST be included: - "@method" - "@target-uri" - "authorization". When the message contains a request body, the covered components MUST also include the following: - "content-digest" The keyid parameter of the signature MUST be set to the kid value of the JWK. See [ietf-httpbis-message-signatures](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-4.1) for more details.'
required: true
optional-signature:
name: Signature
in: header
schema:
type: string
example: 'Signature: sig1=:EWJgAONk3D6542Scj8g51rYeMHw96cH2XiCMxcyL511wyemGcw==:'
description: 'The signature generated based on the Signature-Input, using the signing algorithm specified in the "alg" field of the JWK.'
optional-signature-input:
name: Signature-Input
in: header
schema:
type: string
example: 'Signature-Input: sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-rsa"'
description: 'The Signature-Input field is a Dictionary structured field containing the metadata for one or more message signatures generated from components within the HTTP message. Each member describes a single message signature. The member''s key is the label that uniquely identifies the message signature within the context of the HTTP message. The member''s value is the serialization of the covered components Inner List plus all signature metadata parameters identified by the label. The following components MUST be included: - "@method" - "@target-uri" - "authorization". When the message contains a request body, the covered components MUST also include the following: - "content-digest" The keyid parameter of the signature MUST be set to the kid value of the JWK. See [ietf-httpbis-message-signatures](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-4.1) for more details.'
required: true
security:
- GNAP: []
32 changes: 21 additions & 11 deletions packages/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
createTokenIntrospectionMiddleware,
httpsigMiddleware,
Grant,
RequestAction
RequestAction,
authenticatedStatusMiddleware
} from './open_payments/auth/middleware'
import { RatesService } from './rates/service'
import { spspMiddleware } from './spsp/middleware'
Expand Down Expand Up @@ -129,6 +130,9 @@ export type HttpSigContext = AppContext & {
client: string
}

export type HttpSigWithAuthenticatedStatusContext = HttpSigContext &
AuthenticatedStatusContext

// Payment pointer subresources
type CollectionRequest<BodyT = never, QueryT = ParsedUrlQuery> = Omit<
PaymentPointerContext['request'],
Expand Down Expand Up @@ -165,8 +169,14 @@ type SubresourceContext = Omit<
accessAction: NonNullable<PaymentPointerContext['accessAction']>
}

export type AuthenticatedStatusContext = { authenticated: boolean }

type SignedSubresourceContext = SubresourceContext & HttpSigContext

type SubresourceContextWithAuthenticatedStatus = SubresourceContext &
HttpSigContext &
AuthenticatedStatusContext

export type CreateContext<BodyT> = CollectionContext<BodyT>
export type ReadContext = SubresourceContext
export type CompleteContext = SubresourceContext
Expand Down Expand Up @@ -455,21 +465,21 @@ export class App {

// GET /incoming-payments/{id}
// Read incoming payment
router.get<DefaultState, SignedSubresourceContext>(
router.get<DefaultState, SubresourceContextWithAuthenticatedStatus>(
PAYMENT_POINTER_PATH + '/incoming-payments/:id',
createPaymentPointerMiddleware(),
createValidatorMiddleware<ContextType<SignedSubresourceContext>>(
resourceServerSpec,
{
path: '/incoming-payments/{id}',
method: HttpMethod.GET
}
),
createValidatorMiddleware<
ContextType<SubresourceContextWithAuthenticatedStatus>
>(resourceServerSpec, {
path: '/incoming-payments/{id}',
method: HttpMethod.GET
}),
createTokenIntrospectionMiddleware({
requestType: AccessType.IncomingPayment,
requestAction: RequestAction.Read
requestAction: RequestAction.Read,
bypassError: true
}),
httpsigMiddleware,
authenticatedStatusMiddleware,
incomingPaymentRoutes.get
)

Expand Down
112 changes: 111 additions & 1 deletion packages/backend/src/open_payments/auth/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ import {
} from '@interledger/http-signature-utils'

import {
authenticatedStatusMiddleware,
createTokenIntrospectionMiddleware,
httpsigMiddleware
} from './middleware'
import { Config } from '../../config/app'
import { IocContract } from '@adonisjs/fold'
import { initIocContainer } from '../../'
import { AppServices, HttpSigContext, PaymentPointerContext } from '../../app'
import {
AppServices,
HttpSigContext,
HttpSigWithAuthenticatedStatusContext,
PaymentPointerContext
} from '../../app'
import { createTestApp, TestContainer } from '../../tests/app'
import { createContext } from '../../tests/context'
import { createPaymentPointer } from '../../tests/paymentPointer'
Expand Down Expand Up @@ -69,6 +75,38 @@ describe('Auth Middleware', (): void => {
await appContainer.shutdown()
})

describe('bypassError option', (): void => {
test('calls next for HTTP errors', async (): Promise<void> => {
const middleware = createTokenIntrospectionMiddleware({
requestType: type,
requestAction: action,
bypassError: true
})
ctx.request.headers.authorization = ''

const throwSpy = jest.spyOn(ctx, 'throw')
await expect(middleware(ctx, next)).resolves.toBeUndefined()
expect(throwSpy).toHaveBeenCalledWith(401, 'Unauthorized')
expect(next).toHaveBeenCalled()
})

test('throws error for unkonwn errors', async (): Promise<void> => {
const middleware = createTokenIntrospectionMiddleware({
requestType: type,
requestAction: action,
bypassError: true
})
ctx.request.headers.authorization = ''
const error = new Error('Unknown')
ctx.throw = jest.fn().mockImplementation(() => {
throw error
}) as never

await expect(middleware(ctx, next)).rejects.toBe(error)
expect(next).not.toHaveBeenCalled()
})
})

test.each`
authorization | description
${undefined} | ${'missing'}
Expand Down Expand Up @@ -348,6 +386,78 @@ describe('Auth Middleware', (): void => {
)
})

describe('authenticatedStatusMiddleware', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer

beforeAll(async (): Promise<void> => {
deps = await initIocContainer(Config)
appContainer = await createTestApp(deps)
})

afterAll(async (): Promise<void> => {
await appContainer.shutdown()
})

test('sets ctx.authenticated to false if http signature is invalid', async (): Promise<void> => {
const ctx = createContext<HttpSigWithAuthenticatedStatusContext>({
headers: { 'signature-input': '' }
})

expect(authenticatedStatusMiddleware(ctx, next)).resolves.toBeUndefined()
expect(next).not.toHaveBeenCalled()
expect(ctx.authenticated).toBe(false)
})

test('sets ctx.authenticated to true if http signature is valid', async (): Promise<void> => {
const keyId = uuid()
const privateKey = generateKeyPairSync('ed25519').privateKey
const method = 'GET'
const url = faker.internet.url({ appendSlash: false })
const request = {
method,
url,
headers: {
Accept: 'application/json',
Authorization: `GNAP ${token}`
}
}
const ctx = createContext<HttpSigWithAuthenticatedStatusContext>({
headers: {
Accept: 'application/json',
Authorization: `GNAP ${token}`,
...(await createHeaders({
request,
privateKey,
keyId
}))
},
method,
url
})
ctx.container = deps
ctx.client = faker.internet.url({ appendSlash: false })
const key = generateJwk({
keyId,
privateKey
})

const scope = nock(ctx.client)
.get('/jwks.json')
.reply(200, {
keys: [key]
})

await expect(
authenticatedStatusMiddleware(ctx, next)
).resolves.toBeUndefined()
expect(next).toHaveBeenCalled()
expect(ctx.authenticated).toBe(true)

scope.done()
})
})

describe('HTTP Signature Middleware', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer
Expand Down
41 changes: 36 additions & 5 deletions packages/backend/src/open_payments/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
} from '@interledger/http-signature-utils'
import Koa, { HttpError } from 'koa'
import { Limits, parseLimits } from '../payment/outgoing/limits'
import { HttpSigContext, PaymentPointerContext } from '../../app'
import {
HttpSigContext,
HttpSigWithAuthenticatedStatusContext,
PaymentPointerContext
} from '../../app'
import { AccessAction, AccessType, JWKS } from '@interledger/open-payments'
import { TokenInfo } from 'token-introspection'
import { isActiveTokenInfo } from 'token-introspection'
Expand Down Expand Up @@ -37,16 +41,19 @@ function contextToRequestLike(ctx: HttpSigContext): RequestLike {
body: ctx.request.body ? JSON.stringify(ctx.request.body) : undefined
}
}

export function createTokenIntrospectionMiddleware({
requestType,
requestAction
requestAction,
bypassError = false
}: {
requestType: AccessType
requestAction: RequestAction
bypassError?: boolean
}) {
return async (
ctx: PaymentPointerContext,
next: () => Promise<unknown>
next: () => Promise<void>
): Promise<void> => {
const config = await ctx.container.use('config')
try {
Expand Down Expand Up @@ -119,6 +126,10 @@ export function createTokenIntrospectionMiddleware({
}
await next()
} catch (err) {
if (bypassError && err instanceof HttpError) {
return await next()
}

if (err instanceof HttpError && err.status === 401) {
ctx.status = 401
ctx.message = err.message
Expand All @@ -130,10 +141,23 @@ export function createTokenIntrospectionMiddleware({
}
}

export const httpsigMiddleware = async (
ctx: HttpSigContext,
export const authenticatedStatusMiddleware = async (
ctx: HttpSigWithAuthenticatedStatusContext,
next: () => Promise<unknown>
): Promise<void> => {
ctx.authenticated = false
try {
await throwIfSignatureInvalid(ctx)
ctx.authenticated = true
} catch (err) {
if (err instanceof Koa.HttpError && err.status !== 401) {
throw err
}
}
await next()
}

export const throwIfSignatureInvalid = async (ctx: HttpSigContext) => {
const keyId = getKeyId(ctx.request.headers['signature-input'])
if (!keyId) {
ctx.throw(401, 'Invalid signature input')
Expand Down Expand Up @@ -177,5 +201,12 @@ export const httpsigMiddleware = async (
)
ctx.throw(401, `Invalid signature`)
}
}

export const httpsigMiddleware = async (
ctx: HttpSigContext,
next: () => Promise<unknown>
): Promise<void> => {
await throwIfSignatureInvalid(ctx)
await next()
}
7 changes: 7 additions & 0 deletions packages/backend/src/open_payments/payment/incoming/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,11 @@ export class IncomingPayment
ilpStreamConnection: ilpStreamConnection.toOpenPaymentsType()
}
}

public toPublicOpenPaymentsType(): Pick<
OpenPaymentsIncomingPayment,
'receivedAmount'
> {
return { receivedAmount: serializeAmount(this.receivedAmount) }
}
}
Loading