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): get or create grants #2886

Closed
wants to merge 11 commits into from
Closed
19 changes: 19 additions & 0 deletions packages/backend/migrations/20240820101148_drop_unique_grants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.alterTable('grants', function (table) {
table.dropUnique(['authServerId', 'accessType', 'accessActions'])
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.alterTable('grants', function (table) {
table.unique(['authServerId', 'accessType', 'accessActions'])
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.alterTable('grants', (table) => {
table.timestamp('deletedAt').nullable()
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.alterTable('grants', (table) => {
table.dropColumn('deletedAt')
})
}
2 changes: 2 additions & 0 deletions packages/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import {
} from './open_payments/wallet_address/middleware'

import { LoggingPlugin } from './graphql/plugin'
import { GrantService } from './open_payments/grant/service'
export interface AppContextData {
logger: Logger
container: AppContainer
Expand Down Expand Up @@ -232,6 +233,7 @@ export interface AppServices {
incomingPaymentService: Promise<IncomingPaymentService>
remoteIncomingPaymentService: Promise<RemoteIncomingPaymentService>
receiverService: Promise<ReceiverService>
grantService: Promise<GrantService>
streamServer: Promise<StreamServer>
webhookService: Promise<WebhookService>
quoteService: Promise<QuoteService>
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export function initIocContainer(
container.singleton('grantService', async (deps) => {
return await createGrantService({
authServerService: await deps.use('authServerService'),
openPaymentsClient: await deps.use('openPaymentsClient'),
logger: await deps.use('logger'),
knex: await deps.use('knex')
})
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/open_payments/grant/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum GrantError {
GrantRequiresInteraction = 'GrantRequiresInteraction',
InvalidGrantRequest = 'InvalidGrantRequest'
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export const isGrantError = (o: any): o is GrantError =>
Object.values(GrantError).includes(o)
1 change: 1 addition & 0 deletions packages/backend/src/open_payments/grant/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class Grant extends BaseModel {
public accessType!: AccessType
public accessActions!: AccessAction[]
public expiresAt?: Date | null
public deletedAt?: Date

public get expired(): boolean {
return !!this.expiresAt && this.expiresAt <= new Date()
Expand Down
134 changes: 132 additions & 2 deletions packages/backend/src/open_payments/grant/service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { Grant } from './model'
import { AuthServerService } from '../authServer/service'
import { BaseService } from '../../shared/baseService'
import { AccessAction, AccessType } from '@interledger/open-payments'
import {
AccessAction,
AccessToken,
AccessType,
AuthenticatedClient,
isPendingGrant
} from '@interledger/open-payments'
import { GrantError } from './errors'

export interface GrantService {
create(options: CreateOptions): Promise<Grant>
get(options: GrantOptions): Promise<Grant | undefined>
update(grant: Grant, options: UpdateOptions): Promise<Grant>
getOrCreate(options: GrantOptions): Promise<Grant | GrantError>
delete(id: string): Promise<Grant | GrantError>
}

export interface ServiceDependencies extends BaseService {
authServerService: AuthServerService
openPaymentsClient: AuthenticatedClient
}

export async function createGrantService(
Expand All @@ -26,7 +36,9 @@ export async function createGrantService(
return {
get: (options) => getGrant(deps, options),
create: (options) => createGrant(deps, options),
update: (grant, options) => updateGrant(deps, grant, options)
update: (grant, options) => updateGrant(deps, grant, options),
getOrCreate: (options) => getOrCreateGrant(deps, options),
delete: (id) => deleteGrant(deps, id)
}
}

Expand Down Expand Up @@ -89,6 +101,124 @@ async function updateGrant(
.withGraphFetched('authServer')
}

async function getOrCreateGrant(
deps: ServiceDependencies,
options: GrantOptions
): Promise<Grant | GrantError> {
const existingGrant = await Grant.query(deps.knex)
.findOne({
accessType: options.accessType
})
.whereNull('deletedAt')
.andWhere('authServer.url', options.authServer)
.andWhere('accessActions', '@>', options.accessActions) // all options.accessActions are a subset of saved accessActions
.withGraphJoined('authServer')

if (existingGrant?.expired) {
const updatedGrant = await rotateGrantToken(deps, existingGrant)
if (updatedGrant) {
return updatedGrant
}
} else if (existingGrant) {
return existingGrant
}

let openPaymentsGrant
try {
openPaymentsGrant = await deps.openPaymentsClient.grant.request(
{ url: options.authServer },
{
access_token: {
access: [
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: options.accessType as any,
actions: options.accessActions
}
]
},
interact: {
start: ['redirect']
}
}
)
} catch (err) {
deps.logger.error(
{ err, options },
'Received error requesting Open Payments grant'
)
return GrantError.InvalidGrantRequest
}

if (isPendingGrant(openPaymentsGrant)) {
deps.logger.error({ ...options }, 'Requested grant requires interaction')
return GrantError.GrantRequiresInteraction
}

const { id: authServerId } = await deps.authServerService.getOrCreate(
options.authServer
)

return Grant.query(deps.knex)
.insertAndFetch({
accessType: options.accessType,
accessActions: options.accessActions,
accessToken: openPaymentsGrant.access_token.value,
managementId: retrieveManagementId(openPaymentsGrant.access_token.manage),
authServerId,
expiresAt: openPaymentsGrant.access_token.expires_in
? new Date(
Date.now() + openPaymentsGrant.access_token.expires_in * 1000
)
: undefined
})
.withGraphFetched('authServer')
}

async function rotateGrantToken(
deps: ServiceDependencies,
grant: Grant
): Promise<Grant | undefined> {
if (!grant.authServer) {
deps.logger.error(
{ grantId: grant.id },
'Could not get auth server from grant during token rotation'
)
return undefined
}

let rotatedToken: AccessToken

try {
rotatedToken = await deps.openPaymentsClient.token.rotate({
url: grant.getManagementUrl(grant.authServer.url),
accessToken: grant.accessToken
})
} catch (err) {
deps.logger.warn(
{ err, authServerUrl: grant.authServer.url },
'Grant token rotation failed'
)
await deleteGrant(deps, grant.id)
return undefined
}

return updateGrant(deps, grant, {
accessToken: rotatedToken.access_token.value,
managementUrl: rotatedToken.access_token.manage,
expiresIn: rotatedToken.access_token.expires_in
})
}

async function deleteGrant(
deps: ServiceDependencies,
grantId: string
): Promise<Grant> {
return Grant.query(deps.knex).updateAndFetchById(grantId, {
deletedAt: new Date()
})
}

function retrieveManagementId(managementUrl: string): string {
const managementUrlParts = managementUrl.split('/')
const managementId = managementUrlParts.pop() || managementUrlParts.pop() // handle trailing slash
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GraphQLErrorCode } from '../../../graphql/errors'

export enum RemoteIncomingPaymentError {
NotFound = 'NotFound',
UnknownWalletAddress = 'UnknownWalletAddress',
InvalidRequest = 'InvalidRequest',
InvalidGrant = 'InvalidGrant'
Expand All @@ -15,6 +16,7 @@ export const isRemoteIncomingPaymentError = (
export const errorToHTTPCode: {
[key in RemoteIncomingPaymentError]: number
} = {
[RemoteIncomingPaymentError.NotFound]: 404,
[RemoteIncomingPaymentError.UnknownWalletAddress]: 404,
[RemoteIncomingPaymentError.InvalidRequest]: 500,
[RemoteIncomingPaymentError.InvalidGrant]: 500
Expand All @@ -23,13 +25,15 @@ export const errorToHTTPCode: {
export const errorToCode: {
[key in RemoteIncomingPaymentError]: GraphQLErrorCode
} = {
[RemoteIncomingPaymentError.NotFound]: GraphQLErrorCode.NotFound,
[RemoteIncomingPaymentError.UnknownWalletAddress]: GraphQLErrorCode.NotFound,
[RemoteIncomingPaymentError.InvalidRequest]: GraphQLErrorCode.BadUserInput,
[RemoteIncomingPaymentError.InvalidGrant]: GraphQLErrorCode.Forbidden
}
export const errorToMessage: {
[key in RemoteIncomingPaymentError]: string
} = {
[RemoteIncomingPaymentError.NotFound]: 'unknown incoming payment',
[RemoteIncomingPaymentError.UnknownWalletAddress]: 'unknown wallet address',
[RemoteIncomingPaymentError.InvalidRequest]:
'invalid remote incoming payment request',
Expand Down
Loading
Loading