diff --git a/packages/wallet-service/serverless.yml b/packages/wallet-service/serverless.yml index 684d87c9..514d8a4b 100644 --- a/packages/wallet-service/serverless.yml +++ b/packages/wallet-service/serverless.yml @@ -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 diff --git a/packages/wallet-service/src/api/auth.ts b/packages/wallet-service/src/api/auth.ts index 39c8e535..283bb6d9 100644 --- a/packages/wallet-service/src/api/auth.ts +++ b/packages/wallet-service/src/api/auth.ts @@ -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, @@ -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'; @@ -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(), @@ -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); @@ -154,6 +160,7 @@ export const tokenHandler: APIGatewayProxyHandler = middy(async (event) => { ts: timestamp, addr: address.toString(), wid: walletId, + mode: 'full', }, config.authSecret, { @@ -170,23 +177,125 @@ 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; @@ -194,11 +303,9 @@ const _generatePolicy = (principalId: string, effect: string, resource: string, 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; @@ -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()); diff --git a/packages/wallet-service/src/api/utils.ts b/packages/wallet-service/src/api/utils.ts index 83804e89..666e1a1f 100644 --- a/packages/wallet-service/src/api/utils.ts +++ b/packages/wallet-service/src/api/utils.ts @@ -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 => { - const warmupBefore = (request: middy.Request): APIGatewayProxyResult | undefined => { +export const warmupMiddleware = (): middy.MiddlewareObj => { + const warmupBefore = (request: middy.Request): 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', diff --git a/packages/wallet-service/tests/auth.readonly.test.ts b/packages/wallet-service/tests/auth.readonly.test.ts new file mode 100644 index 00000000..72b63f4b --- /dev/null +++ b/packages/wallet-service/tests/auth.readonly.test.ts @@ -0,0 +1,547 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { APIGatewayProxyResult, APIGatewayTokenAuthorizerEvent, CustomAuthorizerResult } from 'aws-lambda'; +import jwt from 'jsonwebtoken'; +import bitcore from 'bitcore-lib'; + +import { roTokenHandler, bearerAuthorizer, tokenHandler } from '@src/api/auth'; +import { ApiError } from '@src/api/errors'; +import { closeDbConnection, getDbConnection, getWalletId, getAddressFromXpub } from '@src/utils'; +import { WalletStatus } from '@src/types'; +import { + XPUBKEY, + AUTH_XPUBKEY, + addToWalletTable, + cleanDatabase, + makeGatewayEvent, +} from '@tests/utils'; +import config from '@src/config'; + +// Monkey patch bitcore-lib +bitcore.Message.MAGIC_BYTES = Buffer.from('Hathor Signed Message:\n'); + +const mysql = getDbConnection(); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + await closeDbConnection(mysql); +}); + +describe('roTokenHandler', () => { + it('should return a read-only JWT token for a valid ready wallet', async () => { + const walletId = getWalletId(XPUBKEY); + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: 'xpub-auth', + status: WalletStatus.READY, + maxGap: 20, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEvent({}, JSON.stringify({ + xpubkey: XPUBKEY, + })); + + const result = await roTokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(200); + expect(body.success).toBe(true); + expect(body.token).toBeDefined(); + + // Verify JWT structure + const decoded = jwt.verify(body.token, config.authSecret) as any; + expect(decoded.wid).toBe(walletId); + expect(decoded.mode).toBe('ro'); + expect(decoded.jti).toBeDefined(); + expect(decoded.exp).toBeDefined(); + // Should not contain signature data + expect(decoded.sign).toBeUndefined(); + expect(decoded.ts).toBeUndefined(); + expect(decoded.addr).toBeUndefined(); + }); + + it('should return WALLET_NOT_FOUND for non-existent wallet', async () => { + const event = makeGatewayEvent({}, JSON.stringify({ + xpubkey: XPUBKEY, + })); + + const result = await roTokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.WALLET_NOT_FOUND); + }); + + it('should return WALLET_NOT_READY for wallet not in READY status', async () => { + const walletId = getWalletId(XPUBKEY); + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: 'xpub-auth', + status: WalletStatus.CREATING, + maxGap: 20, + createdAt: 10000, + readyAt: null, + }]); + + const event = makeGatewayEvent({}, JSON.stringify({ + xpubkey: XPUBKEY, + })); + + const result = await roTokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.WALLET_NOT_READY); + }); + + it('should return INVALID_PAYLOAD for missing xpubkey', async () => { + const event = makeGatewayEvent({}, JSON.stringify({})); + + const result = await roTokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.INVALID_PAYLOAD); + expect(body.details).toBeDefined(); + expect(body.details[0].message).toContain('xpubkey'); + }); + + it('should return INVALID_PAYLOAD for invalid request body', async () => { + const event = makeGatewayEvent(null); + event.body = 'invalid json'; + + const result = await roTokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.INVALID_PAYLOAD); + }); +}); + +describe('tokenHandler (full-access)', () => { + it('should return INVALID_PAYLOAD for missing required fields', async () => { + const event = makeGatewayEvent({}, JSON.stringify({})); + + const result = await tokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.INVALID_PAYLOAD); + expect(body.details).toBeDefined(); + }); + + it('should return INVALID_PAYLOAD for invalid JSON body', async () => { + const event = makeGatewayEvent(null); + event.body = 'invalid json'; + + const result = await tokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.INVALID_PAYLOAD); + }); + + it('should return WALLET_NOT_FOUND for non-existent wallet', async () => { + const walletId = getWalletId(XPUBKEY); + const now = Math.floor(Date.now() / 1000); + const address = getAddressFromXpub(AUTH_XPUBKEY); + + // Create a valid signature + const message = `${walletId}:${now}`; + const privateKey = new bitcore.PrivateKey(); + const signature = bitcore.Message(message).sign(privateKey); + + const event = makeGatewayEvent({}, JSON.stringify({ + ts: now, + xpub: AUTH_XPUBKEY, + sign: signature, + walletId, + })); + + const result = await tokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.WALLET_NOT_FOUND); + }); + + it('should return error for invalid timestamp', async () => { + const walletId = getWalletId(XPUBKEY); + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: WalletStatus.READY, + maxGap: 20, + createdAt: 10000, + readyAt: 10001, + }]); + + const invalidTimestamp = Math.floor(Date.now() / 1000) - 100000; // Very old timestamp + const address = getAddressFromXpub(AUTH_XPUBKEY); + const message = `${walletId}:${invalidTimestamp}`; + const privateKey = new bitcore.PrivateKey(); + const signature = bitcore.Message(message).sign(privateKey); + + const event = makeGatewayEvent({}, JSON.stringify({ + ts: invalidTimestamp, + xpub: AUTH_XPUBKEY, + sign: signature, + walletId, + })); + + const result = await tokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.AUTH_INVALID_SIGNATURE); + expect(body.details).toBeDefined(); + expect(body.details[0].message).toContain('timestamp is shifted'); + }); + + it('should return error for mismatched auth xpubkey', async () => { + const walletId = getWalletId(XPUBKEY); + const wrongAuthXpub = XPUBKEY; // Using XPUBKEY as wrong auth (stored auth is AUTH_XPUBKEY) + + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: WalletStatus.READY, + maxGap: 20, + createdAt: 10000, + readyAt: 10001, + }]); + + const now = Math.floor(Date.now() / 1000); + const message = `${walletId}:${now}`; + const privateKey = new bitcore.PrivateKey(); + const signature = bitcore.Message(message).sign(privateKey); + + const event = makeGatewayEvent({}, JSON.stringify({ + ts: now, + xpub: wrongAuthXpub, + sign: signature, + walletId, + })); + + const result = await tokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.INVALID_PAYLOAD); + expect(body.details).toBeDefined(); + expect(body.details[0].message).toContain('does not match'); + }); + + it('should return error for invalid signature', async () => { + const walletId = getWalletId(XPUBKEY); + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: WalletStatus.READY, + maxGap: 20, + createdAt: 10000, + readyAt: 10001, + }]); + + const now = Math.floor(Date.now() / 1000); + + const event = makeGatewayEvent({}, JSON.stringify({ + ts: now, + xpub: AUTH_XPUBKEY, + sign: 'invalid-signature', + walletId, + })); + + const result = await tokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.AUTH_INVALID_SIGNATURE); + }); + + it('should successfully generate a full-access token with valid signature', async () => { + const walletId = getWalletId(XPUBKEY); + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: WalletStatus.READY, + maxGap: 20, + createdAt: 10000, + readyAt: 10001, + }]); + + const now = Math.floor(Date.now() / 1000); + const address = getAddressFromXpub(AUTH_XPUBKEY); + const message = `${walletId}:${now}`; + + // Create a proper private key and sign + const privateKey = new bitcore.PrivateKey(); + const addressFromPrivateKey = privateKey.toAddress(); + + // We need to mock verifySignature to return true for this test + // since we can't easily create a valid signature that matches the stored auth xpubkey + const originalVerifySignature = require('@src/utils').verifySignature; + jest.spyOn(require('@src/utils'), 'verifySignature').mockReturnValueOnce(true); + + const signature = bitcore.Message(message).sign(privateKey); + + const event = makeGatewayEvent({}, JSON.stringify({ + ts: now, + xpub: AUTH_XPUBKEY, + sign: signature, + walletId, + })); + + const result = await tokenHandler(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body); + + expect(result.statusCode).toBe(200); + expect(body.success).toBe(true); + expect(body.token).toBeDefined(); + + // Verify JWT structure + const decoded = jwt.verify(body.token, config.authSecret) as any; + expect(decoded.wid).toBe(walletId); + expect(decoded.mode).toBe('full'); + expect(decoded.sign).toBeDefined(); + expect(decoded.ts).toBe(now); + expect(decoded.addr).toBeDefined(); + + // Restore original + jest.restoreAllMocks(); + }); +}); + +describe('bearerAuthorizer with read-only mode', () => { + it('should authorize read-only token with correct policy', async () => { + const walletId = getWalletId(XPUBKEY); + + // Generate a read-only JWT token + const token = jwt.sign( + { + wid: walletId, + mode: 'ro', + }, + config.authSecret, + { + expiresIn: 1800, + jwtid: 'test-jti', + }, + ); + + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/wallet/balances', + authorizationToken: `Bearer ${token}`, + }; + + const result = await bearerAuthorizer(event, null, null) as CustomAuthorizerResult; + + expect(result.principalId).toBe(walletId); + expect(result.context.walletId).toBe(walletId); + expect(result.context.mode).toBe('ro'); + expect(result.policyDocument.Statement[0].Effect).toBe('Allow'); + + // Check that read-only resources are included + const statement = result.policyDocument.Statement[0] as any; + const resources = statement.Resource as string[]; + expect(resources).toContain('arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/*/wallet/balances'); + expect(resources).toContain('arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/*/wallet/status'); + expect(resources).toContain('arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/*/wallet/addresses'); + expect(resources).toContain('arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/*/wallet/history'); + + // Should NOT contain write endpoints + expect(resources).not.toContain('arn:aws:execute-api:us-east-1:123456789012:abcdef123/*/tx/*'); + }); + + it('should authorize full-access token with correct policy', async () => { + const walletId = getWalletId(XPUBKEY); + const now = Math.floor(Date.now() / 1000); + + // Generate a full-access JWT token + const token = jwt.sign( + { + wid: walletId, + mode: 'full', + sign: 'mock-signature', + ts: now, + addr: 'mock-address', + }, + config.authSecret, + { + expiresIn: 1800, + jwtid: 'test-jti', + }, + ); + + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/POST/tx/proposal', + authorizationToken: `Bearer ${token}`, + }; + + const result = await bearerAuthorizer(event, null, null) as CustomAuthorizerResult; + + expect(result.principalId).toBe(walletId); + expect(result.context.walletId).toBe(walletId); + expect(result.context.mode).toBe('full'); + + // Check that full-access resources are included + const statement = result.policyDocument.Statement[0] as any; + const resources = statement.Resource as string[]; + expect(resources).toContain('arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/*/wallet/*'); + expect(resources).toContain('arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/*/tx/*'); + }); + + it('should default to full mode for legacy tokens without mode field', async () => { + const walletId = getWalletId(XPUBKEY); + const now = Math.floor(Date.now() / 1000); + + // Generate a legacy JWT token without mode field + const token = jwt.sign( + { + wid: walletId, + sign: 'mock-signature', + ts: now, + addr: 'mock-address', + }, + config.authSecret, + { + expiresIn: 1800, + jwtid: 'test-jti', + }, + ); + + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/wallet/status', + authorizationToken: `Bearer ${token}`, + }; + + const result = await bearerAuthorizer(event, null, null) as CustomAuthorizerResult; + + expect(result.context.mode).toBe('full'); + }); + + it('should reject expired read-only token', async () => { + const walletId = getWalletId(XPUBKEY); + + // Generate an expired token + const token = jwt.sign( + { + wid: walletId, + mode: 'ro', + }, + config.authSecret, + { + expiresIn: -1, // Expired + jwtid: 'test-jti', + }, + ); + + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/wallet/balances', + authorizationToken: `Bearer ${token}`, + }; + + await expect(bearerAuthorizer(event, null, null)).rejects.toThrow('Unauthorized'); + }); + + it('should reject invalid JWT token', async () => { + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/wallet/balances', + authorizationToken: 'Bearer invalid-token', + }; + + await expect(bearerAuthorizer(event, null, null)).rejects.toThrow('Unauthorized'); + }); + + it('should reject missing authorization token', async () => { + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/wallet/balances', + authorizationToken: null, + }; + + await expect(bearerAuthorizer(event, null, null)).rejects.toThrow('Unauthorized'); + }); + + it('should deny access for full-access token with invalid signature', async () => { + const walletId = getWalletId(XPUBKEY); + const now = Math.floor(Date.now() / 1000); + + // Generate a full-access JWT token with invalid signature + const token = jwt.sign( + { + wid: walletId, + mode: 'full', + sign: 'invalid-signature', + ts: now, + addr: 'invalid-address', + }, + config.authSecret, + { + expiresIn: 1800, + jwtid: 'test-jti', + }, + ); + + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/POST/tx/proposal', + authorizationToken: `Bearer ${token}`, + }; + + const result = await bearerAuthorizer(event, null, null) as CustomAuthorizerResult; + + expect(result.principalId).toBe(walletId); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + it('should throw for unknown jwt verification error', async () => { + // Mock jwt.verify to throw a custom error + jest.spyOn(jwt, 'verify').mockImplementationOnce(() => { + const error: any = new Error('Some unknown error'); + error.name = 'UnknownError'; + throw error; + }); + + const event: APIGatewayTokenAuthorizerEvent = { + type: 'TOKEN', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/wallet/balances', + authorizationToken: 'Bearer some-token', + }; + + await expect(bearerAuthorizer(event, null, null)).rejects.toThrow('Some unknown error'); + + jest.restoreAllMocks(); + }); +});