podcast manager
1import WebSocket from 'isomorphic-ws'
2
3import {combineSignals, timeoutSignal} from '#common/async/aborts'
4import {JWK, jwkExport, jwkImport} from '#common/crypto/jwks'
5import {jwtPayload, jwtSchema, JWTToken, verifyJwtToken} from '#common/crypto/jwts'
6import {normalizeError, ProtocolError} from '#common/errors'
7import {takeSocket} from '#common/socket'
8import {IdentBrand, IdentID, preauthReqSchema, PreauthResponse, RealmBrand, RealmID} from '#realm/protocol/index'
9
10import * as realms from './state'
11
12/**
13 * immediately after the socket connects, we up to 3 seconds for a valid authentication message.
14 * - if the realm does not exist (by realm id), we create a new one, and add the identity (success).
15 * - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey.
16 */
17export async function preauthHandler(ws: WebSocket, signal?: AbortSignal): Promise<realms.AuthenticatedIdentity> {
18 const timeout = timeoutSignal(3000)
19 const combinedSignal = combineSignals(signal, timeout.signal)
20
21 try {
22 const data = await takeSocket(ws, combinedSignal)
23 const jwt = await jwtPayload(preauthReqSchema).parseAsync(data)
24
25 // if any of the parsing fails, it'll throw a zod error
26 const identid = IdentBrand.parse(jwt.claims.iss)
27 const realmid = RealmBrand.parse(jwt.claims.aud)
28
29 switch (jwt.payload.msg) {
30 case 'preauth.register': {
31 // if we're registering, make sure the realm exists
32 const registrantkey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
33 await realms.ensureRegisteredRealm(realmid, identid, registrantkey)
34
35 break
36 }
37
38 case 'preauth.exchange': {
39 // validate and then insert (validation throws)
40 const token = jwtSchema.parse(jwt.payload.dat.inviteJwt)
41 await preauthValidateInvitation(realmid, token)
42
43 const inviteeid = IdentBrand.parse(jwt.claims.iss)
44 const inviteekey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
45 await realms.admitToRealm(realmid, inviteeid, inviteekey)
46
47 break
48 }
49 }
50
51 // everything is in place, fall through to authentication
52 const auth = await authenticatePreauth(realmid, identid, jwt.token)
53 const msg = await preauthResponse(auth, jwt.payload.seq)
54 ws.send(JSON.stringify(msg))
55
56 return auth
57 } finally {
58 timeout.cancel()
59 }
60}
61
62async function preauthValidateInvitation(realmid: RealmID, invitation: JWTToken): Promise<void> {
63 try {
64 const realmMap = await realms.ensureRealmMap()
65 const realm = realmMap.require(realmid)
66
67 if (!invitation.claims.jti) throw new Error('invitation requires nonce!')
68 if (!(await realms.validateNonce(realmid, invitation.claims.jti))) throw new Error('invitation already used!')
69
70 const inviterid = IdentBrand.parse(invitation.claims.iss)
71 const inviterkey = realm.storage.identities.require(inviterid)
72 await verifyJwtToken(invitation.token, inviterkey, {subject: 'invitation'})
73 } catch (exc) {
74 const err = normalizeError(exc)
75 throw new ProtocolError('invitation verification failed', 401, {cause: err})
76 }
77}
78
79async function authenticatePreauth(
80 realmid: RealmID,
81 identid: IdentID,
82 token: string,
83): Promise<realms.AuthenticatedIdentity> {
84 try {
85 const realmMap = await realms.ensureRealmMap()
86 const realm = realmMap.require(realmid)
87 const pubkey = realm.storage.identities.require(identid)
88
89 // at this point we no langer care about the payload
90 // but this throws as a side-effect if the token is invalid
91 await verifyJwtToken(token, pubkey)
92 return {realmid, realm, identid, pubkey}
93 } catch (exc) {
94 const err = normalizeError(exc)
95 throw new ProtocolError('jwt verification failed', 401, {cause: err})
96 }
97}
98
99async function preauthResponse(auth: realms.AuthenticatedIdentity, seq?: number): Promise<PreauthResponse> {
100 const peers: Array<IdentID | 'server'> = Array.from(auth.realm.sockets.keys())
101 peers.unshift('server')
102
103 const identities: Record<IdentID, JWK> = {}
104 for (const identid of auth.realm.storage.identities.keys()) {
105 const pubkey = auth.realm.storage.identities.require(identid)
106 identities[identid] = await jwkExport.parseAsync(pubkey)
107 }
108
109 return {
110 typ: 'res',
111 msg: 'preauth.authn',
112 dat: {peers, identities},
113 seq,
114 }
115}