import EventEmitter from "events" import {on, poll, call, tryCatch} from "@welshman/lib" import {SignedEvent, StampedEvent} from "@welshman/util" import {makeRelayAuth} from "@welshman/util" import {isRelayAuth, isClientAuth, isRelayOk, RelayMessage} from "./message.js" import {Socket, SocketStatus, SocketEvent} from "./socket.js" import {Unsubscriber} from "./util.js" export enum AuthStatus { None = "auth:status:none", Requested = "auth:status:requested", PendingSignature = "auth:status:pending_signature", DeniedSignature = "auth:status:denied_signature", PendingResponse = "auth:status:pending_response", Forbidden = "auth:status:forbidden", Ok = "auth:status:ok", } export type AuthResult = { ok: boolean reason?: string } export enum AuthStateEvent { Status = "auth:event:status", } export type AuthStateEvents = { [AuthStateEvent.Status]: (status: AuthStatus) => void } type Sign = (event: StampedEvent) => Promise export class AuthState extends EventEmitter { challenge: string | undefined request: string | undefined details: string | undefined status = AuthStatus.None _unsubscribers: Unsubscriber[] = [] constructor(readonly socket: Socket) { super() this._unsubscribers.push( on(socket, SocketEvent.Receive, (message: RelayMessage) => { if (isRelayOk(message)) { const [_, id, ok, details] = message if (id === this.request) { this.details = details if (ok) { this.setStatus(AuthStatus.Ok) } else { this.setStatus(AuthStatus.Forbidden) } } } if (isRelayAuth(message)) { const [_, challenge] = message // Sometimes relays send the same challenge multiple times, no need to // respond to it twice if (challenge !== this.challenge) { this.challenge = challenge this.request = undefined this.details = undefined this.setStatus(AuthStatus.Requested) } } }), on(socket, SocketEvent.Sending, (message: RelayMessage) => { if (isClientAuth(message)) { this.setStatus(AuthStatus.PendingResponse) } }), on(socket, SocketEvent.Status, (status: SocketStatus) => { if (status === SocketStatus.Closed || status === SocketStatus.Error) { this.challenge = undefined this.request = undefined this.details = undefined this.setStatus(AuthStatus.None) } }), ) } setStatus(status: AuthStatus) { this.status = status this.emit(AuthStateEvent.Status, status) } async doAuth(sign: Sign) { if (!this.challenge) { throw new Error("Attempted to authenticate with no challenge") } if (this.status !== AuthStatus.Requested) { throw new Error(`Attempted to authenticate when auth is already ${this.status}`) } this.setStatus(AuthStatus.PendingSignature) const template = makeRelayAuth(this.socket.url, this.challenge) const event = await tryCatch(() => sign(template)) if (event) { this.request = event.id this.socket.send(["AUTH", event]) } else { this.setStatus(AuthStatus.DeniedSignature) } } async attemptAuth(sign: Sign) { this.socket.attemptToOpen() if (![AuthStatus.Forbidden, AuthStatus.Ok].includes(this.status)) { await poll({ signal: AbortSignal.timeout(800), condition: () => this.status === AuthStatus.Requested, }) if (this.status === AuthStatus.Requested) { await this.doAuth(sign) } await poll({ signal: AbortSignal.timeout(800), condition: () => this.status !== AuthStatus.PendingResponse, }) } } async retryAuth(sign: Sign) { if (this.challenge) { this.request = undefined this.details = undefined this.setStatus(AuthStatus.Requested) } await this.attemptAuth(sign) } cleanup() { this.removeAllListeners() this._unsubscribers.forEach(call) } }