From 7ffc02ef7acbaf65e2f953444a56cae57859a4a8 Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:08:29 -0400 Subject: [PATCH 1/2] fix(locksmith): harden Privy login and log why logins are rejected (#16607) - loginWithPrivy: return after the wallet/token mismatch 401. The missing return created a session for the mismatched wallet and then crashed on a second send ("Cannot set headers after they are sent"). - loginWithPrivy: tolerate an unparsed body (Express 5 leaves request.body undefined when no parser matched), which surfaced as a misleading 401 "Invalid access token" instead of the 400 "Access token is required". - loginWithPrivy: log request diagnostics (origin, UA, content-type, content-length, body keys) on every rejection so the current wave of 400s on /v2/auth/privy can be attributed from Better Stack. - authMiddleware: the "Unsupported authorization type" branch referenced Express's `response` prototype, threw, and fell through to next(). Drop the broken import and make the fall-through explicit with a warning. Claude-Session: https://claude.ai/code/session_01Xyw4VrQMe4pkTe7aV6WEJd Co-authored-by: Claude Fable 5 --- .../controllers/v2/authController.test.ts | 95 +++++++++++++++++++ .../src/controllers/v2/authController.ts | 39 ++++++-- locksmith/src/utils/middlewares/auth.ts | 10 +- 3 files changed, 132 insertions(+), 12 deletions(-) diff --git a/locksmith/__tests__/controllers/v2/authController.test.ts b/locksmith/__tests__/controllers/v2/authController.test.ts index 2ae1662cbc8..7d8979c17e5 100644 --- a/locksmith/__tests__/controllers/v2/authController.test.ts +++ b/locksmith/__tests__/controllers/v2/authController.test.ts @@ -4,6 +4,8 @@ import app from '../../app' import { afterAll, beforeAll, expect, vi } from 'vitest' import config from '../../../src/config/config' import { privy } from '../../../src/utils/privyClient' +import { Session } from '../../../src/models' +import { logger } from '../../../src/logger' vi.mock('../../../src/utils/privyClient', () => ({ privy: { @@ -151,3 +153,96 @@ describe('Auth login endpoints for locksmith', () => { expect(tokenResponse.status).toBe(401) }) }) + +describe('Privy login hardening', () => { + const walletAddress = '0xabCD567890123456789012345678901234567890' + + const mockPrivy = (tokenUserId: string, walletUserId: string) => { + if (!privy) { + throw new Error('Privy client is not initialized') + } + vi.mocked(privy.verifyAuthToken).mockResolvedValue({ + userId: tokenUserId, + appId: 'mock-app-id', + issuer: 'mock-issuer', + issuedAt: Date.now(), + expiration: Date.now() + 1000 * 60 * 60 * 24, + sessionId: 'mock-session-id', + }) + vi.mocked(privy.getUserByWalletAddress).mockResolvedValue({ + id: walletUserId, + linkedAccounts: [{ type: 'wallet', address: walletAddress }], + } as any) + } + + it('rejects a wallet that does not belong to the token user without creating a session', async () => { + expect.assertions(3) + mockPrivy('user-from-token', 'user-owning-wallet') + const before = await Session.count() + + const response = await request(app) + .post('/v2/auth/privy') + .send({ accessToken: 'token', walletAddress }) + + expect(response.status).toBe(401) + expect(response.body.accessToken).toBeUndefined() + expect(await Session.count()).toBe(before) + }) + + it('returns 400 rather than 401 when the body was not parsed as JSON', async () => { + expect.assertions(2) + mockPrivy('user', 'user') + + const response = await request(app) + .post('/v2/auth/privy') + .set('content-type', 'text/plain') + .send(JSON.stringify({ accessToken: 'token', walletAddress })) + + expect(response.status).toBe(400) + expect(response.body.error).toBe('Access token is required') + }) + + it('logs request diagnostics when rejecting a login with 400', async () => { + expect.assertions(2) + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + const response = await request(app) + .post('/v2/auth/privy') + .set('origin', 'https://app.unlock-protocol.com') + .send({ walletAddress }) + + expect(response.status).toBe(400) + expect(warn).toHaveBeenCalledWith( + 'Privy login rejected', + expect.objectContaining({ + reason: 'Access token is required', + origin: 'https://app.unlock-protocol.com', + contentType: expect.stringContaining('application/json'), + bodyKeys: ['walletAddress'], + }) + ) + warn.mockRestore() + }) +}) + +describe('Authorization header handling', () => { + it('treats an unsupported authorization scheme as unauthenticated and logs it', async () => { + expect.assertions(3) + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + const publicResponse = await request(app) + .get('/v2/auth/nonce') + .set('authorization', 'Basic abc') + const protectedResponse = await request(app) + .get('/v2/auth/user') + .set('authorization', 'Basic abc') + + expect(publicResponse.status).toBe(200) + expect(protectedResponse.status).toBe(401) + expect(warn).toHaveBeenCalledWith( + 'Unsupported authorization scheme', + expect.objectContaining({ scheme: 'basic' }) + ) + warn.mockRestore() + }) +}) diff --git a/locksmith/src/controllers/v2/authController.ts b/locksmith/src/controllers/v2/authController.ts index e24e772a1eb..3ce72e575da 100644 --- a/locksmith/src/controllers/v2/authController.ts +++ b/locksmith/src/controllers/v2/authController.ts @@ -121,17 +121,39 @@ export const login: RequestHandler = async (request, response) => { } } +// Logs enough about a rejected Privy login to diagnose it from the logs alone: +// the request body is not logged by the request logger, so record its shape. +const rejectPrivyLogin = ( + request: Parameters[0], + response: Parameters[1], + status: number, + reason: string +) => { + logger.warn('Privy login rejected', { + reason, + status, + origin: request.headers.origin, + userAgent: request.headers['user-agent'], + contentType: request.headers['content-type'], + contentLength: request.headers['content-length'], + bodyKeys: Object.keys(request.body ?? {}), + }) + response.status(status).json({ error: reason }) +} + export const loginWithPrivy: RequestHandler = async (request, response) => { try { - const { accessToken, walletAddress } = request.body + // Express leaves `request.body` undefined when no body parser matched + // the content type, so treat that the same as an empty body. + const { accessToken, walletAddress } = request.body ?? {} if (!accessToken) { - response.status(400).json({ error: 'Access token is required' }) + rejectPrivyLogin(request, response, 400, 'Access token is required') return } if (!walletAddress) { - response.status(400).json({ error: 'walletAddress is required' }) + rejectPrivyLogin(request, response, 400, 'walletAddress is required') return } @@ -145,10 +167,13 @@ export const loginWithPrivy: RequestHandler = async (request, response) => { const user = await privy.getUserByWalletAddress(walletAddress) if (!user || userAuthClaims.userId !== user.id) { - response.status(401).json({ - error: - 'The wallet you are authenticating with does not match your authentication token', - }) + rejectPrivyLogin( + request, + response, + 401, + 'The wallet you are authenticating with does not match your authentication token' + ) + return } // Create a new session diff --git a/locksmith/src/utils/middlewares/auth.ts b/locksmith/src/utils/middlewares/auth.ts index 8eb2c537aad..c7c56bcdeb6 100644 --- a/locksmith/src/utils/middlewares/auth.ts +++ b/locksmith/src/utils/middlewares/auth.ts @@ -1,5 +1,5 @@ import crypto from 'crypto' -import { RequestHandler, response } from 'express' +import { RequestHandler } from 'express' import { Application } from '../../models/application' import { logger } from '../../logger' import normalizer from '../normalizer' @@ -115,10 +115,10 @@ export const authMiddleware: RequestHandler = async (req, _, next) => { } return next() } - response.status(400).send({ - message: 'Unsupported authorization type', - }) - return + // Unknown schemes are treated as unauthenticated rather than rejected: + // this middleware also fronts public routes. + logger.warn('Unsupported authorization scheme', { scheme: tokenType }) + return next() } catch (error) { logger.info(error.message) return next() From 8f5d4977301125ea41bb474def79790afb947f43 Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:27:31 -0400 Subject: [PATCH 2/2] fix(ci): build workspace packages before deploying provider and graph-service (#16610) Both Cloudflare workers bundle @unlock-protocol/networks (provider also @unlock-protocol/contracts) from the packages' dist/ output, which the production workflow never built. Every production run since #16542 failed these two jobs with "Could not resolve @unlock-protocol/networks: The module ./dist/index.mjs was not found". wedlocks already used the pre-deploy-command input for the same purpose. Claude-Session: https://claude.ai/code/session_01Xyw4VrQMe4pkTe7aV6WEJd Co-authored-by: Claude Fable 5 --- .github/workflows/production.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 3e5193f7e2b..a6093e678c4 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -109,6 +109,8 @@ jobs: with: workspace: '@unlock-protocol/provider' sync-secrets-command: yarn workspace @unlock-protocol/provider set-env-vars + # wrangler bundles these workspace packages from their dist/ output + pre-deploy-command: yarn workspace @unlock-protocol/types build && yarn workspace @unlock-protocol/networks build && yarn workspace @unlock-protocol/contracts build smoke-test-command: | body=$(curl --retry 5 --retry-all-errors --retry-delay 3 -fsS \ -H 'Content-Type: application/json' \ @@ -125,6 +127,8 @@ jobs: with: workspace: graph-service sync-secrets-command: yarn workspace graph-service set-graph-urls + # wrangler bundles @unlock-protocol/networks from its dist/ output + pre-deploy-command: yarn workspace @unlock-protocol/types build && yarn workspace @unlock-protocol/networks build smoke-test-command: | headers=$(mktemp) curl --retry 5 --retry-all-errors --retry-delay 3 -fsS -D "$headers" -o /dev/null 'https://subgraph.unlock-protocol.com/1'