podcast manager
at main 115 lines 4.3 kB view raw
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}