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
4 changes: 4 additions & 0 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand All @@ -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'
Expand Down
95 changes: 95 additions & 0 deletions locksmith/__tests__/controllers/v2/authController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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()
})
})
39 changes: 32 additions & 7 deletions locksmith/src/controllers/v2/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RequestHandler>[0],
response: Parameters<RequestHandler>[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
}

Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions locksmith/src/utils/middlewares/auth.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand Down
Loading