Skip to content

Commit ca69dc4

Browse files
feat: Authenticated Fetch (#45)
Adds the ability to send application request earlier in the handshake, after authenticating the server (but before the server has authenticated us). --------- Co-authored-by: achingbrain <[email protected]>
1 parent 5bfda98 commit ca69dc4

File tree

8 files changed

+172
-76
lines changed

8 files changed

+172
-76
lines changed

examples/peer-id-auth/go-peer/main.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func runServer(privKey crypto.PrivKey) error {
4949
http.Handle("/auth", auth)
5050
wellKnown.AddProtocolMeta(httpauth.ProtocolID, libp2phttp.ProtocolMeta{Path: "/auth"})
5151
auth.Next = func(clientID peer.ID, w http.ResponseWriter, r *http.Request) {
52-
if r.URL.Path != "/log-my-id" {
52+
if r.URL.Path == "/log-my-id" {
5353
fmt.Println("Client ID:", clientID)
5454
}
5555
w.WriteHeader(200)
@@ -63,7 +63,7 @@ func runServer(privKey crypto.PrivKey) error {
6363

6464
func runClient(privKey crypto.PrivKey) error {
6565
auth := httpauth.ClientPeerIDAuth{PrivKey: privKey}
66-
req, err := http.NewRequest("GET", "http://localhost:8001/auth", nil)
66+
req, err := http.NewRequest("GET", "http://localhost:8001/log-my-id", nil)
6767
if err != nil {
6868
return err
6969
}
@@ -79,14 +79,5 @@ func runClient(privKey crypto.PrivKey) error {
7979
}
8080
fmt.Println("Client ID:", myID.String())
8181

82-
req, err = http.NewRequest("GET", "http://localhost:8001/log-my-id", nil)
83-
if err != nil {
84-
return err
85-
}
86-
_, _, err = auth.AuthenticatedDo(http.DefaultClient, req)
87-
if err != nil {
88-
return err
89-
}
90-
9182
return nil
9283
}

examples/peer-id-auth/node.js

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,8 @@ const args = process.argv.slice(2)
1515
if (args.length === 1 && args[0] === 'client') {
1616
// Client mode
1717
const client = new ClientAuth(privKey)
18-
const observedPeerID = await client.authenticateServer('http://localhost:8001/auth')
19-
console.log('Server ID:', observedPeerID.toString())
20-
21-
const authenticatedReq = new Request('http://localhost:8001/log-my-id', {
22-
headers: {
23-
Authorization: client.bearerAuthHeader('localhost:8001')
24-
}
25-
})
26-
await fetch(authenticatedReq)
18+
const { peer: serverID } = await client.authenticatedFetch('http://localhost:8001/log-my-id')
19+
console.log('Server ID:', serverID.toString())
2720
console.log('Client ID:', myID.toString())
2821
process.exit(0)
2922
}
@@ -42,15 +35,13 @@ app.all('/auth', (c) => {
4235
})
4336
wellKnownHandler.registerProtocol(HTTPPeerIDAuthProto, '/auth')
4437

38+
const logMyIDHandler = httpServerAuth.withAuth(async (clientId, req) => {
39+
console.log('Client ID:', clientId.toString())
40+
return new Response('', { status: 200 })
41+
})
42+
4543
app.all('/log-my-id', async (c) => {
46-
try {
47-
const id = await httpServerAuth.unwrapBearerToken('localhost:8001', c.req.header('Authorization'))
48-
console.log('Client ID:', id.toString())
49-
} catch (e) {
50-
console.error(e)
51-
return c.text(e.message, { status: 400 })
52-
}
53-
c.status(200)
44+
return logMyIDHandler(addHeadersProxy(c.req))
5445
})
5546
wellKnownHandler.registerProtocol('/log-my-id/1', '/log-my-id')
5647

src/auth/client.ts

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import { publicKeyFromProtobuf, publicKeyToProtobuf } from '@libp2p/crypto/keys'
22
import { peerIdFromPublicKey } from '@libp2p/peer-id'
33
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays'
44
import { parseHeader, PeerIDAuthScheme, sign, verify } from './common.js'
5+
import { BadResponseError, InvalidPeerError, InvalidSignatureError, MissingAuthHeaderError } from './errors.js'
56
import type { PeerId, PrivateKey } from '@libp2p/interface'
67
import type { AbortOptions } from '@multiformats/multiaddr'
78

8-
interface tokenInfo {
9+
export interface TokenInfo {
910
creationTime: Date
1011
bearer: string
1112
peer: PeerId
1213
}
1314

14-
export interface AuthenticateServerOptions extends AbortOptions {
15+
export interface AuthenticatedFetchOptions extends RequestInit {
1516
/**
1617
* The Fetch implementation to use
1718
*
@@ -24,11 +25,38 @@ export interface AuthenticateServerOptions extends AbortOptions {
2425
* property of `authEndpointURI`
2526
*/
2627
hostname?: string
28+
29+
/**
30+
* A function to verify the peer ID of the server. This function
31+
* will be called after the server has authenticated itself.
32+
* If the function returns false, the request will be aborted.
33+
*/
34+
verifyPeer?(peerId: PeerId, options: AbortOptions): boolean | Promise<boolean>
35+
}
36+
37+
export interface AuthenticateServerOptions extends AbortOptions {
38+
/**
39+
* The Fetch implementation to use
40+
*
41+
* @default globalThis.fetch
42+
*/
43+
fetch?: AuthenticatedFetchOptions['fetch']
44+
45+
/**
46+
* The hostname to use - by default this will be extracted from the `.host`
47+
* property of `authEndpointURI`
48+
*/
49+
hostname?: AuthenticatedFetchOptions['hostname']
50+
}
51+
52+
interface DoAuthenticatedFetchOptions {
53+
fetch?: AuthenticatedFetchOptions['fetch']
54+
hostname?: AuthenticatedFetchOptions['hostname']
2755
}
2856

2957
export class ClientAuth {
3058
key: PrivateKey
31-
tokens = new Map<string, tokenInfo>() // A map from hostname to token
59+
tokens = new Map<string, TokenInfo>() // A map from hostname to token
3260
tokenTTL = 60 * 60 * 1000 // 1 hour
3361

3462
constructor (key: PrivateKey, opts?: { tokenTTL?: number }) {
@@ -51,7 +79,7 @@ export class ClientAuth {
5179
return `${PeerIDAuthScheme} ${encodedParams}`
5280
}
5381

54-
public bearerAuthHeader (hostname: string): string | undefined {
82+
public bearerAuthHeaderWithPeer (hostname: string): { 'authorization': string, peer: PeerId } | undefined {
5583
const token = this.tokens.get(hostname)
5684
if (token == null) {
5785
return undefined
@@ -60,17 +88,48 @@ export class ClientAuth {
6088
this.tokens.delete(hostname)
6189
return undefined
6290
}
63-
return `${PeerIDAuthScheme} bearer="${token.bearer}"`
91+
return { authorization: `${PeerIDAuthScheme} bearer="${token.bearer}"`, peer: token.peer }
6492
}
6593

66-
public async authenticateServer (authEndpointURI: string | URL, options?: AuthenticateServerOptions): Promise<PeerId> {
67-
authEndpointURI = new URL(authEndpointURI)
94+
public bearerAuthHeader (hostname: string): string | undefined {
95+
return this.bearerAuthHeaderWithPeer(hostname)?.authorization
96+
}
97+
98+
/**
99+
* authenticatedFetch is like `fetch`, but it also handles HTTP Peer ID
100+
* authentication with the server.
101+
*
102+
* If we have not seen the server before, verifyPeer will be called to check
103+
* if we want to make the request to the server with the given peer id. This
104+
* happens after we've authenticated the server.
105+
*/
106+
public async authenticatedFetch (request: string | URL | Request, options?: AuthenticatedFetchOptions): Promise<Response & { peer: PeerId }> {
107+
const { fetch, hostname, verifyPeer, ...requestOpts } = options ?? {}
108+
let req: Request
109+
if (request instanceof Request && Object.keys(requestOpts).length === 0) {
110+
req = request
111+
} else {
112+
req = new Request(request, requestOpts)
113+
}
114+
const verifyPeerWithDefault = verifyPeer ?? (() => true)
115+
116+
const { response, peer } = await this.doAuthenticatedFetch(req, verifyPeerWithDefault, { fetch, hostname })
117+
118+
const responseWithPeer: Response & { peer: PeerId } = response as Response & { peer: PeerId }
119+
responseWithPeer.peer = peer
120+
return responseWithPeer
121+
}
122+
123+
private async doAuthenticatedFetch (request: Request, verifyPeer: (server: PeerId, options: AbortOptions) => boolean | Promise<boolean>, options?: DoAuthenticatedFetchOptions): Promise<{ peer: PeerId, response: Response }> {
124+
const authEndpointURI = new URL(request.url)
68125
const hostname = options?.hostname ?? authEndpointURI.host
126+
const fetch = options?.fetch ?? globalThis.fetch
69127

70128
if (this.tokens.has(hostname)) {
71-
const token = this.tokens.get(hostname)
72-
if (token !== undefined && Date.now() - token.creationTime.getTime() < this.tokenTTL) {
73-
return token.peer
129+
const token = this.bearerAuthHeaderWithPeer(hostname)
130+
if (token !== undefined) {
131+
request.headers.set('Authorization', token.authorization)
132+
return { peer: token.peer, response: await fetch(request) }
74133
} else {
75134
this.tokens.delete(hostname)
76135
}
@@ -87,16 +146,15 @@ export class ClientAuth {
87146
})
88147
}
89148

90-
const fetch = options?.fetch ?? globalThis.fetch
91149
const resp = await fetch(authEndpointURI, {
92150
headers,
93-
signal: options?.signal
151+
signal: request.signal
94152
})
95153

96154
// Verify the server's challenge
97155
const authHeader = resp.headers.get('www-authenticate')
98156
if (authHeader == null) {
99-
throw new Error('No auth header')
157+
throw new MissingAuthHeaderError('No auth header')
100158
}
101159
const authFields = parseHeader(authHeader)
102160
const serverPubKeyBytes = uint8ArrayFromString(authFields['public-key'], 'base64urlpad')
@@ -107,7 +165,14 @@ export class ClientAuth {
107165
['client-public-key', marshaledClientPubKey],
108166
['challenge-server', challengeServer]], uint8ArrayFromString(authFields.sig, 'base64urlpad'))
109167
if (!valid) {
110-
throw new Error('Invalid signature')
168+
throw new InvalidSignatureError('Invalid signature')
169+
}
170+
171+
const serverPublicKey = publicKeyFromProtobuf(serverPubKeyBytes)
172+
const serverID = peerIdFromPublicKey(serverPublicKey)
173+
174+
if (!await verifyPeer(serverID, { signal: request.signal })) {
175+
throw new InvalidPeerError('Id check failed')
111176
}
112177

113178
const sig = await sign(this.key, PeerIDAuthScheme, [
@@ -120,31 +185,30 @@ export class ClientAuth {
120185
sig: uint8ArrayToString(sig, 'base64urlpad')
121186
})
122187

123-
const resp2 = await fetch(authEndpointURI, {
124-
headers: {
125-
Authorization: authenticateSelfHeaders
126-
},
127-
signal: options?.signal
128-
})
188+
request.headers.set('Authorization', authenticateSelfHeaders)
189+
const resp2 = await fetch(request)
190+
191+
if (!resp2.ok) {
192+
throw new BadResponseError(`Unexpected status code ${resp.status}`)
193+
}
129194

130-
// Verify the server's signature
131195
const serverAuthHeader = resp2.headers.get('Authentication-Info')
132196
if (serverAuthHeader == null) {
133-
throw new Error('No server auth header')
134-
}
135-
if (resp2.status !== 200) {
136-
throw new Error('Unexpected status code')
197+
throw new MissingAuthHeaderError('No server auth header')
137198
}
138199

139200
const serverAuthFields = parseHeader(serverAuthHeader)
140-
const serverPublicKey = publicKeyFromProtobuf(serverPubKeyBytes)
141-
const serverID = peerIdFromPublicKey(serverPublicKey)
142201
this.tokens.set(hostname, {
143202
peer: serverID,
144203
creationTime: new Date(),
145204
bearer: serverAuthFields.bearer
146205
})
147206

148-
return serverID
207+
return { peer: serverID, response: resp2 }
208+
}
209+
210+
public async authenticateServer (authEndpointURI: string | URL, options?: AuthenticateServerOptions): Promise<PeerId> {
211+
const req = new Request(authEndpointURI, { signal: options?.signal })
212+
return (await this.authenticatedFetch(req, options)).peer
149213
}
150214
}

src/auth/errors.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export class MissingAuthHeaderError extends Error {
2+
static name = 'MissingAuthHeaderError'
3+
name = 'MissingAuthHeaderError'
4+
}
5+
6+
export class InvalidSignatureError extends Error {
7+
static name = 'InvalidSignatureError'
8+
name = 'InvalidSignatureError'
9+
}
10+
11+
export class InvalidPeerError extends Error {
12+
static name = 'InvalidPeerError'
13+
name = 'InvalidPeerError'
14+
}
15+
16+
export class BadResponseError extends Error {
17+
static name = 'BadResponseError'
18+
name = 'BadResponseError'
19+
}

src/auth/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export { PeerIDAuthScheme, HTTPPeerIDAuthProto } from './common.js'
22
export { ClientAuth } from './client.js'
33
export { ServerAuth } from './server.js'
4+
5+
export type { AuthenticatedFetchOptions, AuthenticateServerOptions, TokenInfo } from './client.js'
6+
export type { ServerAuthOps, HttpHandler } from './server.js'

0 commit comments

Comments
 (0)