Re-work connection auth

This commit is contained in:
Jon Staab
2024-10-14 15:18:21 -07:00
parent e025a8de36
commit f841de2a50
10 changed files with 171 additions and 115 deletions
+7 -32
View File
@@ -1,7 +1,8 @@
import {ctx, Emitter, Worker, sleep} from '@welshman/lib'
import {AuthStatus, ConnectionMeta} from './ConnectionMeta'
import {Emitter, Worker, sleep} from '@welshman/lib'
import {ConnectionMeta} from './ConnectionMeta'
import {ConnectionAuth, AuthStatus} from './ConnectionAuth'
import {Socket, isMessage, asMessage} from './Socket'
import type {SocketMessage, Message} from './Socket'
import type {SocketMessage} from './Socket'
export class Connection extends Emitter {
url: string
@@ -9,6 +10,7 @@ export class Connection extends Emitter {
sender: Worker<SocketMessage>
receiver: Worker<SocketMessage>
meta: ConnectionMeta
auth: ConnectionAuth
constructor(url: string) {
super()
@@ -18,6 +20,7 @@ export class Connection extends Emitter {
this.sender = this.createSender()
this.receiver = this.createReceiver()
this.meta = new ConnectionMeta(this)
this.auth = new ConnectionAuth(this)
this.setMaxListeners(100)
}
@@ -45,7 +48,7 @@ export class Connection extends Emitter {
}
// Only defer for auth if we're not multiplexing
if (isMessage(message) && ![AuthStatus.Ok, AuthStatus.Pending].includes(this.meta.authStatus)) {
if (isMessage(message) && ![AuthStatus.None, AuthStatus.Ok].includes(this.auth.status)) {
return true
}
@@ -93,14 +96,6 @@ export class Connection extends Emitter {
}
onReceive = (message: SocketMessage) => {
const [verb, ...extra] = asMessage(message)
if (verb === 'AUTH') {
const [challenge] = extra
ctx.net.onAuth(this.url, challenge)
}
this.emit('receive', this, message)
}
@@ -121,26 +116,6 @@ export class Connection extends Emitter {
}
}
ensureAuth = async ({timeout = 3000} = {}) => {
await this.ensureConnected()
if ([AuthStatus.Unauthorized, AuthStatus.Pending].includes(this.meta.authStatus)) {
await Promise.race([
sleep(timeout),
new Promise<void>(resolve => {
const onReceive = (cxn: Connection, message: Message) => {
if (message[0] === 'OK' && message[2]) {
this.off('receive', onReceive)
resolve()
}
}
this.on('receive', onReceive)
})
])
}
}
disconnect() {
this.socket.disconnect()
this.sender.clear()
+131
View File
@@ -0,0 +1,131 @@
import {ctx, sleep} from '@welshman/lib'
import {CLIENT_AUTH, createEvent} from '@welshman/util'
import type {Connection} from './Connection'
import type {SocketMessage} from './Socket'
import {asMessage} from './Socket'
export enum AuthMode {
Implicit = 'implicit',
Explicit = 'explicit',
}
export enum AuthStatus {
None = 'none',
Requested = 'requested',
PendingSignature = 'pending_signature',
DeniedSignature = 'denied_signature',
PendingResponse = 'pending_response',
Forbidden = 'forbidden',
Ok = 'ok',
}
const {
None,
Requested,
PendingSignature,
DeniedSignature,
PendingResponse,
Forbidden,
Ok,
} = AuthStatus
export class ConnectionAuth {
challenge: string | undefined
request: string | undefined
message: string | undefined
status = None
constructor(readonly connection: Connection) {
this.connection.on('receive', this.#onReceive)
}
#onReceive = (connection: Connection, message: SocketMessage) => {
const [verb, ...extra] = asMessage(message)
if (verb === 'OK') {
const [id, ok, message] = extra
if (id === this.request) {
this.challenge = undefined
this.request = undefined
this.message = message
this.status = ok ? Ok : Forbidden
}
}
if (verb === 'AUTH' && extra[0] !== this.challenge) {
this.challenge = extra[0]
this.request = undefined
this.message = undefined
this.status = Requested
if (ctx.net.authMode === AuthMode.Implicit) {
this.attempt()
}
}
}
attempt = async () => {
if (!this.challenge) {
throw new Error("Attempted to authenticate with no challenge")
}
if (this.status !== Requested) {
throw new Error(`Attempted to authenticate when auth is already ${this.status}`)
}
this.status = PendingSignature
const template = createEvent(CLIENT_AUTH, {
tags: [
["relay", this.connection.url],
["challenge", this.challenge],
],
})
const [event] = await Promise.all([
ctx.net.signEvent(template),
this.connection.ensureConnected(),
])
if (event) {
this.request = event.id
this.connection.send(['AUTH', event])
this.status = PendingResponse
} else {
this.status = DeniedSignature
}
}
attemptIfRequested = async () => {
if (this.status === Requested) {
await this.attempt()
}
}
wait = async ({timeout = 3000}: {timeout?: number} = {}) => {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
await sleep(100)
if ([None, Requested].includes(this.status)) {
throw new Error("Auth flow reset while waiting for auth")
}
if ([DeniedSignature, Forbidden, Ok].includes(this.status)) {
break
}
}
}
waitIfPending = async ({timeout = 3000}: {timeout?: number} = {}) => {
if ([PendingSignature, PendingResponse].includes(this.status)) {
await this.wait({timeout})
}
}
destroy = () => {
this.connection.off('recieve', this.#onReceive)
}
}
+8 -34
View File
@@ -13,16 +13,7 @@ export type RequestMeta = {
eoseReceived: boolean
}
export enum AuthStatus {
Ok = 'ok',
Pending = 'pending',
Unauthorized = 'unauthorized',
Forbidden = 'forbidden',
}
export enum ConnectionStatus {
Unauthorized = 'unauthorized',
Forbidden = 'forbidden',
Error = 'error',
Closed = 'closed',
Slow = 'slow',
@@ -30,7 +21,6 @@ export enum ConnectionStatus {
}
export class ConnectionMeta {
authStatus = AuthStatus.Pending
pendingPublishes = new Map<string, PublishMeta>()
pendingRequests = new Map<string, RequestMeta>()
publishCount = 0
@@ -96,12 +86,8 @@ export class ConnectionMeta {
}
onReceiveOk([verb, eventId, ok, notice]: Message) {
const publish = this.pendingPublishes.get(eventId)
if (ok) {
this.authStatus = AuthStatus.Ok
} else if (notice?.startsWith('auth-required:')) {
// Re-enqueue pending events when auth challenge is received
// Re-enqueue pending events when auth challenge is received
if (notice?.startsWith('auth-required:')) {
const pub = this.pendingPublishes.get(eventId)
if (pub) {
@@ -109,6 +95,8 @@ export class ConnectionMeta {
}
}
const publish = this.pendingPublishes.get(eventId)
if (publish) {
this.responseCount++
this.responseTimer += Date.now() - publish.sent
@@ -116,8 +104,7 @@ export class ConnectionMeta {
}
}
onReceiveAuth([verb, eventId]: Message) {
this.authStatus = AuthStatus.Unauthorized
onReceiveAuth(message: Message) {
this.lastAuth = Date.now()
}
@@ -139,8 +126,8 @@ export class ConnectionMeta {
}
onReceiveClosed([verb, id, notice]: Message) {
if (notice.startsWith('auth-required:')) {
// Re-enqueue pending reqs when auth challenge is received
// Re-enqueue pending reqs when auth challenge is received
if (notice?.startsWith('auth-required:')) {
const req = this.pendingRequests.get(id)
if (req) {
@@ -163,24 +150,11 @@ export class ConnectionMeta {
getStatus = () => {
const socket = this.cxn.socket
if (this.authStatus === AuthStatus.Unauthorized) return ConnectionStatus.Unauthorized
if (this.authStatus === AuthStatus.Forbidden) return ConnectionStatus.Forbidden
if (socket.isNew()) return ConnectionStatus.Closed
if (this.lastFault && this.lastFault > this.lastOpen) return ConnectionStatus.Error
if (socket.isClosed() || socket.isClosing()) return ConnectionStatus.Closed
if (this.getSpeed() > 1000) return ConnectionStatus.Slow
if (this.getSpeed() > 2000) return ConnectionStatus.Slow
return ConnectionStatus.Ok
}
getDescription = () => {
switch (this.getStatus()) {
case ConnectionStatus.Unauthorized: return 'Logging in'
case ConnectionStatus.Forbidden: return 'Failed to log in'
case ConnectionStatus.Error: return 'Failed to connect'
case ConnectionStatus.Closed: return 'Waiting to reconnect'
case ConnectionStatus.Slow: return 'Slow Connection'
case ConnectionStatus.Ok: return 'Connected'
}
}
}
+8 -5
View File
@@ -1,16 +1,18 @@
import {ctx, uniq, noop, always} from '@welshman/lib'
import {matchFilters, unionFilters, isSignedEvent, hasValidSignature} from '@welshman/util'
import type {Filter, TrustedEvent} from '@welshman/util'
import type {StampedEvent, SignedEvent, Filter, TrustedEvent} from '@welshman/util'
import {Pool} from "./Pool"
import {Executor} from "./Executor"
import {AuthMode} from "./ConnectionAuth"
import {Relays} from "./target/Relays"
import type {Subscription, RelaysAndFilters} from "./Subscribe"
export type NetContext = {
pool: Pool
getExecutor: (relays: string[]) => Executor
authMode: AuthMode,
onEvent: (url: string, event: TrustedEvent) => void
onAuth: (url: string, challenge: string) => void
signEvent: (event: StampedEvent) => Promise<SignedEvent>
getExecutor: (relays: string[]) => Executor
isDeleted: (url: string, event: TrustedEvent) => boolean
isValid: (url: string, event: TrustedEvent) => boolean
matchFilters: (url: string, filters: Filter[], event: TrustedEvent) => boolean
@@ -27,9 +29,10 @@ export const defaultOptimizeSubscriptions = (subs: Subscription[]) =>
})
export const getDefaultNetContext = (overrides: Partial<NetContext> = {}) => ({
onAuth: noop,
onEvent: noop,
pool: new Pool(),
authMode: AuthMode.Implicit,
onEvent: noop,
signEvent: noop,
isDeleted: always(false),
isValid: (url: string, event: TrustedEvent) => isSignedEvent(event) && hasValidSignature(event),
getExecutor: (relays: string[]) => new Executor(new Relays(relays.map((relay: string) => ctx.net.pool.get(relay)))),
+1 -1
View File
@@ -291,7 +291,7 @@ const _executeSubscription = (sub: Subscription) => {
Promise.all(
executor.target.connections.map(async (connection: Connection) => {
if (authTimeout) {
await connection.ensureAuth({timeout: authTimeout})
await connection.auth.waitIfPending({timeout: authTimeout})
}
})
).then(() => {
+1
View File
@@ -1,4 +1,5 @@
export * from "./Connection"
export * from "./ConnectionAuth"
export * from "./ConnectionMeta"
export * from "./Context"
export * from "./Executor"