Skip to content
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
12 changes: 12 additions & 0 deletions packages/wallet-service/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,18 @@ functions:
warmup:
walletWarmer:
enabled: true
authROTokenApi:
handler: src/api/auth.roTokenHandler
timeout: 6
memorySize: 1024
events:
- http:
path: auth/token/readonly
method: post
cors: true
warmup:
walletWarmer:
enabled: true
bearerAuthorizer:
handler: src/api/auth.bearerAuthorizer
memorySize: 1024
Expand Down
148 changes: 129 additions & 19 deletions packages/wallet-service/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { v4 as uuid4 } from 'uuid';
import Joi from 'joi';
import jwt from 'jsonwebtoken';
import { ApiError } from '@src/api/errors';
import { Wallet } from '@src/types';
import { Wallet, WalletStatus } from '@src/types';
import { getWallet } from '@src/db';
import {
verifySignature,
Expand All @@ -25,6 +25,7 @@ import {
getDbConnection,
validateAuthTimestamp,
AUTH_MAX_TIMESTAMP_SHIFT_IN_SECONDS,
getWalletId,
} from '@src/utils';
import { warmupMiddleware } from '@src/api/utils';
import middy from '@middy/core';
Expand All @@ -35,6 +36,7 @@ import config from '@src/config';
import errorHandler from '@src/api/middlewares/errorHandler';

const EXPIRATION_TIME_IN_SECONDS = 1800;
const READONLY_EXPIRATION_TIME_IN_SECONDS = 1800; // 30 minutes

const bodySchema = Joi.object({
ts: Joi.number().positive().required(),
Expand All @@ -43,6 +45,10 @@ const bodySchema = Joi.object({
walletId: Joi.string().required(),
});

const readOnlyBodySchema = Joi.object({
xpubkey: Joi.string().required(),
});

function parseBody(body) {
try {
return JSON.parse(body);
Expand Down Expand Up @@ -154,6 +160,7 @@ export const tokenHandler: APIGatewayProxyHandler = middy(async (event) => {
ts: timestamp,
addr: address.toString(),
wid: walletId,
mode: 'full',
},
config.authSecret,
{
Expand All @@ -170,35 +177,135 @@ export const tokenHandler: APIGatewayProxyHandler = middy(async (event) => {
.use(warmupMiddleware())
.use(errorHandler());

export const roTokenHandler: APIGatewayProxyHandler = middy(async (event) => {
const eventBody = parseBody(event.body);

const { value, error } = readOnlyBodySchema.validate(eventBody, {
abortEarly: false,
convert: false,
});

if (error) {
await closeDbConnection(mysql);

const details = error.details.map((err) => ({
message: err.message,
path: err.path,
}));

return {
statusCode: 400,
body: JSON.stringify({
success: false,
error: ApiError.INVALID_PAYLOAD,
details,
}),
};
}

const xpubkey = value.xpubkey;
const walletId = getWalletId(xpubkey);

// Check if wallet exists and is ready
const wallet: Wallet = await getWallet(mysql, walletId);

if (!wallet) {
await closeDbConnection(mysql);
return {
statusCode: 400,
body: JSON.stringify({
success: false,
error: ApiError.WALLET_NOT_FOUND,
}),
};
}

if (wallet.status !== WalletStatus.READY) {
await closeDbConnection(mysql);
return {
statusCode: 400,
body: JSON.stringify({
success: false,
error: ApiError.WALLET_NOT_READY,
}),
};
}

// Generate JWT with read-only mode
// NOTE: JWT does NOT contain xpubkey, only walletId hash
const token = jwt.sign(
{
wid: walletId,
mode: 'ro',
},
config.authSecret,
{
expiresIn: READONLY_EXPIRATION_TIME_IN_SECONDS,
jwtid: uuid4(),
},
);

await closeDbConnection(mysql);

return {
statusCode: 200,
body: JSON.stringify({ success: true, token }),
};
}).use(cors())
.use(warmupMiddleware())
.use(errorHandler());

/**
* Generates a aws policy document to allow/deny access to the resource
*/
const _generatePolicy = (principalId: string, effect: string, resource: string, logger: Logger) => {
const _generatePolicy = (principalId: string, effect: string, resource: string, logger: Logger, mode: string = 'full') => {
const resourcePrefix = `${resource.split('/').slice(0, 2).join('/')}/*`;
const policyDocument: PolicyDocument = {
Version: '2012-10-17',
Statement: [],
};

// Define resources based on mode
let allowedResources: string[];

if (mode === 'ro') {
// Read-only endpoints
allowedResources = [
`${resourcePrefix}/wallet/status`,
`${resourcePrefix}/wallet/addresses`,
`${resourcePrefix}/wallet/addresses/new`,
`${resourcePrefix}/wallet/balances`,
`${resourcePrefix}/wallet/tokens`,
`${resourcePrefix}/wallet/tokens/*/details`,
`${resourcePrefix}/wallet/history`,
`${resourcePrefix}/wallet/utxos`,
`${resourcePrefix}/wallet/tx_outputs`,
`${resourcePrefix}/wallet/transactions/*`,
`${resourcePrefix}/wallet/address/info`,
`${resourcePrefix}/wallet/proxy/*`,
];
} else {
// Full access
allowedResources = [
`${resourcePrefix}/wallet/*`,
`${resourcePrefix}/tx/*`,
];
}

const statementOne: Statement = {
Action: 'execute-api:Invoke',
Effect: effect,
Resource: [
`${resourcePrefix}/wallet/*`,
`${resourcePrefix}/tx/*`,
],
Resource: allowedResources,
};

policyDocument.Statement[0] = statementOne;

const authResponse: CustomAuthorizerResult = {
policyDocument,
principalId,
context: { walletId: principalId, mode },
};

const context = { walletId: principalId };
authResponse.context = context;

// XXX: to get the resulting policy on the logs, since we can't check the cached policy
logger.info('Generated policy:', authResponse);
return authResponse;
Expand Down Expand Up @@ -233,20 +340,23 @@ export const bearerAuthorizer: APIGatewayTokenAuthorizerHandler = middy(async (e
}
}

// signature data
const signature = data.sign;
const timestamp = data.ts;
const address = data.addr;
const walletId = data.wid;
const mode = data.mode || 'full'; // Default to full for legacy tokens

// header data
const expirationTs = data.exp;
const verified = verifySignature(signature, timestamp, address, walletId);
// For full-access tokens, verify wallet signature (existing logic)
if (mode === 'full') {
const signature = data.sign;
const timestamp = data.ts;
const address = data.addr;
const verified = verifySignature(signature, timestamp, address, walletId);

if (verified && Math.floor(Date.now() / 1000) <= expirationTs) {
return _generatePolicy(walletId, 'Allow', event.methodArn, logger);
if (!verified) {
return _generatePolicy(walletId, 'Deny', event.methodArn, logger, mode);
}
}

return _generatePolicy(walletId, 'Deny', event.methodArn, logger);
// For read-only tokens, JWT is already verified above - no wallet signature needed
// Generate appropriate policy based on mode
return _generatePolicy(walletId, 'Allow', event.methodArn, logger, mode);
}).use(cors())
.use(warmupMiddleware());
7 changes: 5 additions & 2 deletions packages/wallet-service/src/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,13 @@ export const closeDbAndGetError = async (

/**
* Will return early if the request is a wake-up call from serverless-plugin-warmup
* Generic to work with both APIGatewayProxyResult and APIGatewayAuthorizerResult
*/
export const warmupMiddleware = (): middy.MiddlewareObj<APIGatewayProxyEvent, APIGatewayProxyResult> => {
const warmupBefore = (request: middy.Request): APIGatewayProxyResult | undefined => {
export const warmupMiddleware = <TEvent = APIGatewayProxyEvent, TResult = APIGatewayProxyResult>(): middy.MiddlewareObj<TEvent, TResult> => {
const warmupBefore = (request: middy.Request<TEvent, TResult>): TResult | undefined => {
// @ts-expect-error - checking for warmup source property
if (request.event.source === 'serverless-plugin-warmup') {
// @ts-expect-error - returning a generic warmup response
return {
statusCode: 200,
body: 'OK',
Expand Down
Loading