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: support cookies between requests #49

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions .aegir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('aegir').PartialOptions} */
export default {
dependencyCheck: {
ignore: [
'undici' // required by http-cookie-agent
]
}
}
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,13 @@
"@multiformats/multiaddr": "^12.3.0",
"@multiformats/multiaddr-to-uri": "^11.0.0",
"@perseveranza-pets/milo": "^0.2.1",
"http-cookie-agent": "^6.0.7",
"p-defer": "^4.0.1",
"tough-cookie": "^5.0.0",
"uint8-varint": "^2.0.4",
"uint8arraylist": "^2.4.8",
"uint8arrays": "^5.1.0"
"uint8arrays": "^5.1.0",
"undici": "^6.21.0"
},
"devDependencies": {
"@libp2p/interface-compliance-tests": "^6.1.8",
Expand All @@ -187,5 +190,8 @@
"libp2p": "^2.2.1",
"sinon-ts": "^2.0.0"
},
"browser": {
"./dist/src/auth/agent.js": "./dist/src/auth/agent.browser.js"
},
"sideEffects": false
}
11 changes: 11 additions & 0 deletions src/auth/agent.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Returns nothing as browsers handle cookies for us
*/
export function getAgent (): any {
return {
agent: undefined,
jar: {
getCookies: () => []
}
}
}
18 changes: 18 additions & 0 deletions src/auth/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { CookieAgent } from 'http-cookie-agent/undici'
import { CookieJar } from 'tough-cookie'

/**
* Returns an HTTP Agent that handles cookies over multiple requests
*/
export function getAgent (): { agent: CookieAgent, jar: CookieJar } {
const jar = new CookieJar()

return {
agent: new CookieAgent({
cookies: {
jar
}
}),
jar
}
}
44 changes: 40 additions & 4 deletions src/auth/client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { publicKeyFromProtobuf, publicKeyToProtobuf } from '@libp2p/crypto/keys'
import { peerIdFromPublicKey } from '@libp2p/peer-id'
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays'
import { getAgent } from './agent.js'
import { parseHeader, PeerIDAuthScheme, sign, verify } from './common.js'
import { BadResponseError, InvalidPeerError, InvalidSignatureError, MissingAuthHeaderError } from './errors.js'
import type { PeerId, PrivateKey } from '@libp2p/interface'
import type { AbortOptions } from '@multiformats/multiaddr'
import type { CookieAgent } from 'http-cookie-agent/undici'
import type { CookieJar } from 'tough-cookie'

export interface TokenInfo {
creationTime: Date
bearer: string
peer: PeerId
agent: CookieAgent
jar: CookieJar
}

export interface AuthenticatedFetchOptions extends RequestInit {
Expand Down Expand Up @@ -79,7 +84,7 @@
return `${PeerIDAuthScheme} ${encodedParams}`
}

public bearerAuthHeaderWithPeer (hostname: string): { 'authorization': string, peer: PeerId } | undefined {
public bearerAuthHeaderWithPeer (hostname: string): { 'authorization': string, peer: PeerId, agent: CookieAgent, jar: CookieJar } | undefined {
const token = this.tokens.get(hostname)
if (token == null) {
return undefined
Expand All @@ -88,7 +93,12 @@
this.tokens.delete(hostname)
return undefined
}
return { authorization: `${PeerIDAuthScheme} bearer="${token.bearer}"`, peer: token.peer }
return {
authorization: `${PeerIDAuthScheme} bearer="${token.bearer}"`,
peer: token.peer,
agent: token.agent,
jar: token.jar
}

Check warning on line 101 in src/auth/client.ts

View check run for this annotation

Codecov / codecov/patch

src/auth/client.ts#L96-L101

Added lines #L96 - L101 were not covered by tests
}

public bearerAuthHeader (hostname: string): string | undefined {
Expand Down Expand Up @@ -128,7 +138,12 @@
if (this.tokens.has(hostname)) {
const token = this.bearerAuthHeaderWithPeer(hostname)
if (token !== undefined) {
// @ts-expect-error not in types
request.dispatcher = token.agent

Check warning on line 142 in src/auth/client.ts

View check run for this annotation

Codecov / codecov/patch

src/auth/client.ts#L141-L142

Added lines #L141 - L142 were not covered by tests
request.headers.set('Authorization', token.authorization)

await addCookiesToRequest(request, token.jar)

Check warning on line 146 in src/auth/client.ts

View check run for this annotation

Codecov / codecov/patch

src/auth/client.ts#L144-L146

Added lines #L144 - L146 were not covered by tests
return { peer: token.peer, response: await fetch(request) }
} else {
this.tokens.delete(hostname)
Expand All @@ -146,9 +161,15 @@
})
}

const { agent, jar } = getAgent()

const resp = await fetch(authEndpointURI, {
method: 'OPTIONS',
headers,
signal: request.signal
signal: request.signal,

// @ts-expect-error not in types
dispatcher: agent
})

// Verify the server's challenge
Expand Down Expand Up @@ -185,7 +206,12 @@
sig: uint8ArrayToString(sig, 'base64urlpad')
})

// @ts-expect-error not in types
request.dispatcher = agent
request.headers.set('Authorization', authenticateSelfHeaders)

await addCookiesToRequest(request, jar)

const resp2 = await fetch(request)

if (!resp2.ok) {
Expand All @@ -201,7 +227,9 @@
this.tokens.set(hostname, {
peer: serverID,
creationTime: new Date(),
bearer: serverAuthFields.bearer
bearer: serverAuthFields.bearer,
agent,
jar
})

return { peer: serverID, response: resp2 }
Expand All @@ -212,3 +240,11 @@
return (await this.authenticatedFetch(req, options)).peer
}
}

async function addCookiesToRequest (request: Request, jar: CookieJar): Promise<void> {
const cookies = await jar.getCookies(request.url.toString())

cookies.forEach(cookie => {
request.headers.append('Cookie', cookie.cookieString())
})
}
35 changes: 28 additions & 7 deletions src/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays'
import { encodeAuthParams, parseHeader, PeerIDAuthScheme, sign, verify } from './common.js'
import type { PeerId, PrivateKey, PublicKey, Logger } from '@libp2p/interface'
import type http from 'node:http'

export interface HttpHandler { (req: Request): Promise<Response> }

Expand Down Expand Up @@ -32,7 +33,7 @@

withAuth (httpAuthedHandler: (peer: PeerId, req: Request) => Promise<Response>): HttpHandler {
return async (req: Request): Promise<Response> => {
const authResp = await this.authenticateRequest(req)
const authResp = await this.authenticateRequest(this.readHostname(req), req.headers.get('Authorization') ?? undefined)
if (authResp.status !== 200 || authResp.peer === undefined) {
return new Response('', { status: authResp.status, headers: authResp.headers })
}
Expand All @@ -55,17 +56,37 @@
}
}

requestListener (httpAuthedHandler: (peer: PeerId, req: http.IncomingMessage, res: http.ServerResponse) => void): http.RequestListener {
return (req: http.IncomingMessage, res: http.ServerResponse): void => {
Promise.resolve()
.then(async () => {
const authResp = await this.authenticateRequest(req.headers.host ?? '', req.headers.authorization)

for (const [key, value] of Object.entries(authResp.headers ?? {})) {
res.setHeader(key, value)
}

if (authResp.status !== 200 || authResp.peer === undefined) {
res.statusCode = authResp.status
res.end()

return
}

httpAuthedHandler(authResp.peer, req, res)
})
.catch(err => {
this.logger?.error('error handling request - %e', err)

Check warning on line 79 in src/auth/server.ts

View check run for this annotation

Codecov / codecov/patch

src/auth/server.ts#L79

Added line #L79 was not covered by tests
})
}
}

/* eslint-disable-next-line complexity */
private async authenticateRequest (req: Request): Promise<AuthenticationResponse> {
const hostname = this.readHostname(req)
private async authenticateRequest (hostname: string, authHeader?: string): Promise<AuthenticationResponse> {
if (!this.validHostname(hostname)) {
return { status: 400 }
}

if (req.headers === undefined) {
return this.returnChallenge(hostname, null, {})
}
const authHeader = req.headers.get('Authorization')
if (authHeader === null || authHeader === undefined || authHeader === '') {
return this.returnChallenge(hostname, null, {})
}
Expand Down
2 changes: 1 addition & 1 deletion test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('whatwg-fetch', () => {
return streams[1]
})

clientComponents.connectionManager.openConnection.callsFake(async (peer: PeerId | Multiaddr | Multiaddr[], options?: any) => {
clientComponents.connectionManager.openConnection.callsFake(async (peer: PeerId | Multiaddr | Multiaddr[]) => {
if (Array.isArray(peer)) {
peer = peer[0]
}
Expand Down
99 changes: 99 additions & 0 deletions test/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import http from 'node:http'
import { generateKeyPair } from '@libp2p/crypto/keys'
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
import { expect } from 'aegir/chai'
import pDefer from 'p-defer'
import { ClientAuth, ServerAuth } from '../src/auth/index.js'
import type { PeerId, PrivateKey } from '@libp2p/interface'

describe('@libp2p/http-fetch', () => {
describe('client auth', () => {
let clientKey: PrivateKey
let serverKey: PrivateKey
let server: http.Server

beforeEach(async () => {
clientKey = await generateKeyPair('Ed25519')
serverKey = await generateKeyPair('Ed25519')
})

afterEach(async () => {
server?.close()
server?.closeAllConnections()
})

it('should perform auth from client', async () => {
const clientAuth = new ClientAuth(clientKey)
const serverAuth = new ServerAuth(serverKey, h => h.startsWith('127.0.0.1'))
const clientPeer = pDefer<PeerId>()
const echoListener = serverAuth.requestListener((clientId, req, res) => {
clientPeer.resolve(clientId)
req.pipe(res)
})

server = http.createServer(echoListener)

const port = await new Promise<number>((resolve, reject) => {
const listener = server.listen(0, () => {
const address = listener.address()

if (address == null || typeof address === 'string') {
reject(new Error('Could not listen on port'))
return
}

Check warning on line 43 in test/node.ts

View check run for this annotation

Codecov / codecov/patch

test/node.ts#L41-L43

Added lines #L41 - L43 were not covered by tests

resolve(address.port)
})
})

await expect(clientAuth.authenticateServer(`http://127.0.0.1:${port}`)).to.eventually.deep.equal(peerIdFromPrivateKey(serverKey))
await expect(clientPeer.promise).to.eventually.deep.equal(peerIdFromPrivateKey(clientKey))
})

it('should respect cookies during auth', async () => {
const clientAuth = new ClientAuth(clientKey)
const serverAuth = new ServerAuth(serverKey, h => h.startsWith('127.0.0.1'))
const cookie = pDefer<string>()
const echoListener = serverAuth.requestListener((clientId, req, res) => {
req.pipe(res)
})
const cookieName = 'test-cookie-name'
const cookieValue = 'test-cookie-value'
let requests = 0

server = http.createServer((req, res) => {
requests++

const cookieHeader = req.headers.cookie

if (cookieHeader == null) {
if (requests === 2) {
cookie.reject(new Error('No cookie header found on second request'))
}

Check warning on line 72 in test/node.ts

View check run for this annotation

Codecov / codecov/patch

test/node.ts#L71-L72

Added lines #L71 - L72 were not covered by tests

res.setHeader('set-cookie', `${cookieName}=${cookieValue}; Expires=${new Date(Date.now() + 86_400_000).toString()}; HttpOnly`)
} else {
cookie.resolve(cookieHeader)
}

echoListener(req, res)
})

const port = await new Promise<number>((resolve, reject) => {
const listener = server.listen(0, () => {
const address = listener.address()

if (address == null || typeof address === 'string') {
reject(new Error('Could not listen on port'))
return
}

Check warning on line 89 in test/node.ts

View check run for this annotation

Codecov / codecov/patch

test/node.ts#L87-L89

Added lines #L87 - L89 were not covered by tests

resolve(address.port)
})
})

await expect(clientAuth.authenticateServer(`http://127.0.0.1:${port}`)).to.eventually.deep.equal(peerIdFromPrivateKey(serverKey))
await expect(cookie.promise).to.eventually.equal(`${cookieName}=${cookieValue}`)
})
})
})
Loading