Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,13 @@ interface ControlHandshakeResponse {
// fatal, returned by the custom handshake handler
| 'REJECTED_BY_CUSTOM_HANDLER'
| 'REJECTED_UNSUPPORTED_CLIENT';
// Application-defined rejection details. Older peers ignore this
// optional field.
details?: {
code: string;
message: string;
extras?: unknown;
};
};
}

Expand Down Expand Up @@ -628,6 +635,7 @@ The server will send an error response if either:
- server is in the future (`server.seq > client.nextExpectedSeq`)

When the client receives a status with `ok: false`, it should consider the handshake failed and close the connection.
Custom handshake handlers can attach application-defined `details` to a rejection. River preserves its own `code` for protocol behavior and exposes `details` on the client's `handshake_failed` protocol error event. Applications can use `details.code` for decisions without parsing the human-readable `reason` or `message`.

### Re-handshaking (live credential refresh)

Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -807,8 +807,9 @@ createServer(serverTransport, services, {
// from?: TransportClientId,
// ) =>
// | 'REJECTED_BY_CUSTOM_HANDLER' | 'REJECTED_UNSUPPORTED_CLIENT' (if you reject it)
// | HandshakeRejection (if you reject it with structured details)
// | ParsedMetadata (if you allow it)
// | a Promise of either
// | a Promise of any of the above
//
// next time a connection happens on the same session, previousMetadata will
// be populated with the last returned value. `from` is the client id the peer
Expand All @@ -820,6 +821,27 @@ createServer(serverTransport, services, {
});
```

Use `rejectHandshake` when the client needs a machine-readable reason for an application-level rejection:

```ts
createServerHandshakeOptions(handshakeSchema, async (metadata) => {
const authenticated = await authenticate(metadata.token);
if (!authenticated.ok) {
return rejectHandshake({
code: 'TOKEN_EXPIRED',
message: 'The authentication token expired',
extras: { expiredAt: authenticated.expiredAt },
});
}

return { parsedToken: metadata.token };
});
```

River sends these details on the optional `details` field of the failed handshake response and exposes them on the client's `handshake_failed` protocol error event. Existing failure codes remain available for simple handlers. Do not put secrets or raw internal errors in `message` or `extras` because River sends them to the peer.

During a re-handshake, River exposes structured rejection details only on the server's `handshake_failed` event before it closes the session. The client's next fresh handshake can receive the details.

`createClientHandshakeOptions` also takes an optional third `eager` argument. When set, the
client constructs handshake metadata as soon as it starts dialing, so a slow `construct`
(e.g. fetching a fresh token) overlaps establishing the connection instead of running after
Expand Down
24 changes: 17 additions & 7 deletions __tests__/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import {
createClientHandshakeOptions,
createServerHandshakeOptions,
rejectHandshake,
} from '../router/handshake';
import { RehandshakeStreamId } from '../transport/message';
import { TestSetupHelpers } from '../testUtil/fixtures/transports';
Expand Down Expand Up @@ -1483,13 +1484,14 @@ describe.each(testMatrix())(
'client',
createClientHandshakeOptions(requestSchema, construct),
);
const validate = vi.fn(
(
metadata: ParsedMetadata,
): ParsedMetadata | 'REJECTED_BY_CUSTOM_HANDLER' =>
metadata.token === 'token-v1'
? { token: metadata.token }
: 'REJECTED_BY_CUSTOM_HANDLER',
const rejectionDetails = {
code: 'TOKEN_EXPIRED',
message: 'The refreshed token expired',
};
const validate = vi.fn((metadata: ParsedMetadata) =>
metadata.token === 'token-v1'
? { token: metadata.token }
: rejectHandshake(rejectionDetails),
);
const serverTransport = getServerTransport<
typeof requestSchema,
Expand All @@ -1504,6 +1506,8 @@ describe.each(testMatrix())(
addPostTestCleanup(async () => {
await cleanupTransports([clientTransport, serverTransport]);
});
const serverHandshakeFailed = vi.fn();
serverTransport.addEventListener('protocolError', serverHandshakeFailed);

const ServiceSchema = createServiceSchema<
MaybeDisposable,
Expand Down Expand Up @@ -1539,6 +1543,12 @@ describe.each(testMatrix())(
expect(serverTransport.sessions.has('client')).toBe(false),
);
await waitFor(() => expect(numberOfConnections(clientTransport)).toBe(0));
expect(serverHandshakeFailed).toHaveBeenCalledWith({
type: 'handshake_failed',
code: 'REJECTED_BY_CUSTOM_HANDLER',
message: 're-handshake metadata rejected by handshake handler',
details: rejectionDetails,
});

// let the client's now-disconnected session lapse before cleanup
await advanceFakeTimersBySessionGrace();
Expand Down
18 changes: 5 additions & 13 deletions protobuf/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,19 @@ import type {
MessageInitShape,
MessageShape,
} from '@bufbuild/protobuf';
import { type Static } from 'typebox';
import {
createClientHandshakeOptions as createTransportClientHandshakeOptions,
createServerHandshakeOptions as createTransportServerHandshakeOptions,
type ClientHandshakeOptions,
type HandshakeValidationResult,
type ServerHandshakeOptions,
} from '../router/handshake';
import {
HandshakeErrorCustomHandlerFatalResponseCodes,
type TransportClientId,
} from '../transport/message';
import { type TransportClientId } from '../transport/message';
import { decodeMessageBytes, encodeMessageBytes } from './shared';
import { Uint8ArrayType } from '../customSchemas';

const HandshakeBytesSchema = Uint8ArrayType();

type ProtobufHandshakeFailureCode = Static<
typeof HandshakeErrorCustomHandlerFatalResponseCodes
>;

type ConstructHandshake<Schema extends DescMessage> = () =>
| MessageInitShape<Schema>
| Promise<MessageInitShape<Schema>>;
Expand All @@ -32,9 +25,8 @@ type ValidateHandshake<Schema extends DescMessage, ParsedMetadata> = (
previousParsedMetadata?: ParsedMetadata,
from?: TransportClientId,
) =>
| ParsedMetadata
| ProtobufHandshakeFailureCode
| Promise<ParsedMetadata | ProtobufHandshakeFailureCode>;
| HandshakeValidationResult<ParsedMetadata>
| Promise<HandshakeValidationResult<ParsedMetadata>>;

/**
* Create client-side handshake options backed by a protobuf message type.
Expand Down Expand Up @@ -73,7 +65,7 @@ export function createServerHandshakeOptions<
try {
decoded = decodeMessageBytes(schema, metadata);
} catch {
return 'REJECTED_BY_CUSTOM_HANDLER' as ProtobufHandshakeFailureCode;
return 'REJECTED_BY_CUSTOM_HANDLER';
}

return await validate(decoded, previousParsedMetadata, from);
Expand Down
5 changes: 5 additions & 0 deletions protobuf/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export {
createClientHandshakeOptions,
createServerHandshakeOptions,
} from './handshake';
export { rejectHandshake } from '../router/handshake';
export type {
HandshakeRejection,
HandshakeRejectionDetails,
} from '../router/handshake';
export { createProtoService } from './service';
export type {
AnyProtoService,
Expand Down
53 changes: 45 additions & 8 deletions router/handshake.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
import type { Static, TSchema } from 'typebox';
import {
HandshakeErrorCustomHandlerFatalResponseCodes,
HandshakeRejectionDetailsSchema,
type TransportClientId,
} from '../transport/message';

const handshakeRejectionBrand: unique symbol = Symbol('handshakeRejection');

export type HandshakeRejectionDetails = Static<
typeof HandshakeRejectionDetailsSchema
>;

export interface HandshakeRejection {
readonly [handshakeRejectionBrand]: true;
responseCode: Static<typeof HandshakeErrorCustomHandlerFatalResponseCodes>;
details: HandshakeRejectionDetails;
}

export function rejectHandshake(
details: HandshakeRejectionDetails,
responseCode: Static<
typeof HandshakeErrorCustomHandlerFatalResponseCodes
> = 'REJECTED_BY_CUSTOM_HANDLER',
): HandshakeRejection {
return {
[handshakeRejectionBrand]: true,
responseCode,
details,
};
}

export function isHandshakeRejection(
value: unknown,
): value is HandshakeRejection {
return (
typeof value === 'object' &&
value !== null &&
handshakeRejectionBrand in value
);
}

export type HandshakeValidationResult<ParsedMetadata> =
| Static<typeof HandshakeErrorCustomHandlerFatalResponseCodes>
| HandshakeRejection
| ParsedMetadata;

type ConstructHandshake<T extends TSchema> = () =>
| Static<T>
| Promise<Static<T>>;
Expand All @@ -13,12 +54,8 @@ type ValidateHandshake<T extends TSchema, ParsedMetadata> = (
previousParsedMetadata?: ParsedMetadata,
from?: TransportClientId,
) =>
| Static<typeof HandshakeErrorCustomHandlerFatalResponseCodes>
| ParsedMetadata
| Promise<
| Static<typeof HandshakeErrorCustomHandlerFatalResponseCodes>
| ParsedMetadata
>;
| HandshakeValidationResult<ParsedMetadata>
| Promise<HandshakeValidationResult<ParsedMetadata>>;

export interface ClientHandshakeOptions<
MetadataSchema extends TSchema = TSchema,
Expand Down Expand Up @@ -57,8 +94,8 @@ export interface ServerHandshakeOptions<

/**
* Parses the metadata sent by the client during the handshake into the
* server-side {@link ParsedMetadata}, or returns a handshake failure code to
* reject the connection.
* server-side {@link ParsedMetadata}, or returns a handshake failure code or
* {@link HandshakeRejection} to reject the connection.
*
* @param metadata - The metadata sent by the client.
* @param previousParsedMetadata - The parsed metadata from the previous
Expand Down
5 changes: 5 additions & 0 deletions router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,10 @@ export type {
export {
createClientHandshakeOptions,
createServerHandshakeOptions,
rejectHandshake,
} from './handshake';
export type {
HandshakeRejection,
HandshakeRejectionDetails,
} from './handshake';
export { version as RIVER_VERSION } from '../package.json';
8 changes: 8 additions & 0 deletions transport/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,10 +377,17 @@ export abstract class ClientTransport<
);

const reason = `handshake failed: ${msg.payload.status.reason}`;
const { details } = msg.payload.status;
const to = session.to;
this.rejectHandshakeResponse(session, reason, {
...session.loggingMetadata,
transportMessage: msg,
...(details && {
extras: {
...session.loggingMetadata.extras,
handshakeRejectionDetails: details,
},
}),
});

if (retriable) {
Expand All @@ -390,6 +397,7 @@ export abstract class ClientTransport<
type: ProtocolError.HandshakeFailed,
code: msg.payload.status.code,
message: reason,
...(details && { details }),
});
}

Expand Down
7 changes: 6 additions & 1 deletion transport/events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Static } from 'typebox';
import { Connection } from './connection';
import { OpaqueTransportMessage, HandshakeErrorResponseCodes } from './message';
import {
OpaqueTransportMessage,
HandshakeErrorResponseCodes,
HandshakeRejectionDetailsSchema,
} from './message';
import { Session, SessionState } from './sessionStateMachine';
import { SessionId } from './sessionStateMachine/common';
import { TransportStatus } from './transport';
Expand Down Expand Up @@ -38,6 +42,7 @@ export interface EventMap {
type: (typeof ProtocolError)['HandshakeFailed'];
code: Static<typeof HandshakeErrorResponseCodes>;
message: string;
details?: Static<typeof HandshakeRejectionDetailsSchema>;
}
| {
type: Omit<
Expand Down
1 change: 1 addition & 0 deletions transport/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
export {
TransportMessageSchema,
OpaqueTransportMessageSchema,
HandshakeRejectionDetailsSchema,
isStreamOpen,
isStreamClose,
} from './message';
Expand Down
51 changes: 51 additions & 0 deletions transport/message.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { TransportMessage } from '.';
import {
ControlMessageHandshakeResponseSchema,
ControlFlags,
HandshakeErrorResponseCodes,
handshakeRequestMessage,
handshakeResponseMessage,
isAck,
isStreamClose,
isStreamOpen,
} from './message';
import { describe, test, expect } from 'vitest';
import { Type } from 'typebox';
import { Value } from 'typebox/value';

const msg = (
to: string,
Expand Down Expand Up @@ -105,6 +109,53 @@ describe('message helpers', () => {
expect(mFail.payload.status.ok).toBe(false);
});

test('structured handshake rejections are compatible with older clients', () => {
const oldHandshakeResponseSchema = Type.Object({
type: Type.Literal('HANDSHAKE_RESP'),
status: Type.Union([
Type.Object({
ok: Type.Literal(true),
sessionId: Type.String(),
}),
Type.Object({
ok: Type.Literal(false),
reason: Type.String(),
code: HandshakeErrorResponseCodes,
}),
]),
});
const payload = {
type: 'HANDSHAKE_RESP',
status: {
ok: false,
reason: 'rejected by handshake handler',
code: 'REJECTED_BY_CUSTOM_HANDLER',
details: {
code: 'TOKEN_EXPIRED',
message: 'The authentication token expired',
},
},
};

expect(Value.Check(oldHandshakeResponseSchema, payload)).toBe(true);
expect(Value.Check(ControlMessageHandshakeResponseSchema, payload)).toBe(
true,
);
});

test('handshake rejections without details remain valid', () => {
expect(
Value.Check(ControlMessageHandshakeResponseSchema, {
type: 'HANDSHAKE_RESP',
status: {
ok: false,
reason: 'rejected by handshake handler',
code: 'REJECTED_BY_CUSTOM_HANDLER',
},
}),
).toBe(true);
});

test('default message has no control flags set', () => {
const m = msg('a', 'b', 'stream', { test: 1 }, 'svc', 'proc');

Expand Down
Loading
Loading