diff --git a/packages/connect/src/actions/connect.test.ts b/packages/connect/src/actions/app/connect.test.ts similarity index 94% rename from packages/connect/src/actions/connect.test.ts rename to packages/connect/src/actions/app/connect.test.ts index 3e73b87..d149f2e 100644 --- a/packages/connect/src/actions/connect.test.ts +++ b/packages/connect/src/actions/app/connect.test.ts @@ -1,4 +1,4 @@ -import { createAppClient } from "../clients/createAppClient"; +import { createAppClient } from "../../clients/createAppClient"; import { jest } from "@jest/globals"; describe("connect", () => { diff --git a/packages/connect/src/actions/connect.ts b/packages/connect/src/actions/app/connect.ts similarity index 50% rename from packages/connect/src/actions/connect.ts rename to packages/connect/src/actions/app/connect.ts index e62be9b..c635ed5 100644 --- a/packages/connect/src/actions/connect.ts +++ b/packages/connect/src/actions/app/connect.ts @@ -1,7 +1,8 @@ -import { Client } from "../clients/createClient"; -import { AsyncHttpResponse, post } from "../clients/transports/http"; +import { Client } from "../../clients/createClient"; +import { AsyncHttpResponse, post } from "../../clients/transports/http"; export type ConnectArgs = ConnectRequest; +export type ConnectResponse = AsyncHttpResponse; interface ConnectRequest { siweUri: string; @@ -12,13 +13,13 @@ interface ConnectRequest { requestId?: string; } -export interface ConnectResponse { +interface ConnectAPIResponse { channelToken: string; connectURI: string; } const path = "connect"; -export const connect = async (client: Client, { ...request }: ConnectArgs): AsyncHttpResponse => { - return post(client, path, request); +export const connect = async (client: Client, { ...request }: ConnectArgs): ConnectResponse => { + return post(client, path, request); }; diff --git a/packages/connect/src/actions/status.test.ts b/packages/connect/src/actions/app/status.test.ts similarity index 93% rename from packages/connect/src/actions/status.test.ts rename to packages/connect/src/actions/app/status.test.ts index 45f399b..45ca761 100644 --- a/packages/connect/src/actions/status.test.ts +++ b/packages/connect/src/actions/app/status.test.ts @@ -1,4 +1,4 @@ -import { createAppClient } from "../clients/createAppClient"; +import { createAppClient } from "../../clients/createAppClient"; import { jest } from "@jest/globals"; describe("status", () => { diff --git a/packages/connect/src/actions/status.ts b/packages/connect/src/actions/app/status.ts similarity index 63% rename from packages/connect/src/actions/status.ts rename to packages/connect/src/actions/app/status.ts index ec936d1..86682a8 100644 --- a/packages/connect/src/actions/status.ts +++ b/packages/connect/src/actions/app/status.ts @@ -1,11 +1,13 @@ -import { Client } from "../clients/createClient"; -import { get, AsyncHttpResponse } from "../clients/transports/http"; +import { Client } from "../../clients/createClient"; +import { get, AsyncHttpResponse } from "../../clients/transports/http"; export interface StatusArgs { channelToken: string; } -export interface StatusResponse { +export type StatusResponse = AsyncHttpResponse; + +interface StatusAPIResponse { state: "pending" | "completed"; nonce: string; connectURI: string; @@ -20,6 +22,6 @@ export interface StatusResponse { const path = "connect/status"; -export const status = async (client: Client, { channelToken }: StatusArgs): AsyncHttpResponse => { +export const status = async (client: Client, { channelToken }: StatusArgs): StatusResponse => { return get(client, path, { authToken: channelToken }); }; diff --git a/packages/connect/src/actions/app/verifySignInMessage.ts b/packages/connect/src/actions/app/verifySignInMessage.ts new file mode 100644 index 0000000..bebffad --- /dev/null +++ b/packages/connect/src/actions/app/verifySignInMessage.ts @@ -0,0 +1,21 @@ +import { Client } from "../../clients/createClient"; +import { SignInResponse, verify } from "../../messages/verify"; + +export interface VerifySignInMessageArgs { + message: string; + signature: `0x${string}`; +} + +export type VerifySignInMessageResponse = Promise; + +export const verifySignInMessage = async ( + _client: Client, + { message, signature }: VerifySignInMessageArgs, +): VerifySignInMessageResponse => { + const result = await verify(message, signature); + if (result.isErr()) { + throw result.error; + } else { + return result.value; + } +}; diff --git a/packages/connect/src/actions/authenticate.test.ts b/packages/connect/src/actions/auth/authenticate.test.ts similarity index 96% rename from packages/connect/src/actions/authenticate.test.ts rename to packages/connect/src/actions/auth/authenticate.test.ts index ee09bd4..88f0a72 100644 --- a/packages/connect/src/actions/authenticate.test.ts +++ b/packages/connect/src/actions/auth/authenticate.test.ts @@ -1,4 +1,4 @@ -import { createAuthClient } from "../clients/createAuthClient"; +import { createAuthClient } from "../../clients/createAuthClient"; import { jest } from "@jest/globals"; describe("authenticate", () => { diff --git a/packages/connect/src/actions/auth/authenticate.ts b/packages/connect/src/actions/auth/authenticate.ts new file mode 100644 index 0000000..1c356c8 --- /dev/null +++ b/packages/connect/src/actions/auth/authenticate.ts @@ -0,0 +1,30 @@ +import { StatusResponse } from "../app/status"; +import { post, AsyncHttpResponse } from "../../clients/transports/http"; +import { Client } from "../../clients/createClient"; + +export interface AuthenticateArgs extends AuthenticateRequest { + channelToken: string; +} + +export type AuthenticateResponse = AsyncHttpResponse; + +interface AuthenticateRequest { + message: string; + signature: `0x${string}`; + fid: number; + username: string; + bio: string; + displayName: string; + pfpUrl: string; +} + +type AuthenticateAPIResponse = StatusResponse; + +const path = "connect/authenticate"; + +export const authenticate = async ( + client: Client, + { channelToken, ...request }: AuthenticateArgs, +): AuthenticateResponse => { + return post(client, path, request, { authToken: channelToken }); +}; diff --git a/packages/connect/src/actions/auth/buildSignInMessage.ts b/packages/connect/src/actions/auth/buildSignInMessage.ts new file mode 100644 index 0000000..4274b66 --- /dev/null +++ b/packages/connect/src/actions/auth/buildSignInMessage.ts @@ -0,0 +1,15 @@ +import { Client } from "clients/createClient"; +import { build, SignInMessageParams } from "../../messages/build"; +import { SiweMessage } from "siwe"; + +export type BuildSignInMessageArgs = SignInMessageParams; +export type BuildSignInMessageResponse = SiweMessage; + +export const buildSignInMessage = (_client: Client, args: BuildSignInMessageArgs): BuildSignInMessageResponse => { + const result = build(args); + if (result.isErr()) { + throw result.error; + } else { + return result.value; + } +}; diff --git a/packages/connect/src/actions/authenticate.ts b/packages/connect/src/actions/authenticate.ts deleted file mode 100644 index a6e51a6..0000000 --- a/packages/connect/src/actions/authenticate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StatusResponse } from "./status"; -import { post, AsyncHttpResponse } from "../clients/transports/http"; -import { Client } from "../clients/createClient"; - -export interface AuthenticateArgs extends AuthenticateRequest { - channelToken: string; -} - -interface AuthenticateRequest { - message: string; - signature: `0x${string}`; - fid: number; - username: string; - bio: string; - displayName: string; - pfpUrl: string; -} - -export type AuthenticateResponse = StatusResponse; - -const path = "connect/authenticate"; - -export const authenticate = async ( - client: Client, - { channelToken, ...request }: AuthenticateArgs, -): AsyncHttpResponse => { - return post(client, path, request, { authToken: channelToken }); -}; diff --git a/packages/connect/src/clients/createAppClient.ts b/packages/connect/src/clients/createAppClient.ts index c39855f..3d5879b 100644 --- a/packages/connect/src/clients/createAppClient.ts +++ b/packages/connect/src/clients/createAppClient.ts @@ -1,11 +1,16 @@ -import { connect, ConnectArgs, ConnectResponse } from "../actions/connect"; -import { status, StatusArgs, StatusResponse } from "../actions/status"; +import { connect, ConnectArgs, ConnectResponse } from "../actions/app/connect"; +import { status, StatusArgs, StatusResponse } from "../actions/app/status"; +import { + verifySignInMessage, + VerifySignInMessageArgs, + VerifySignInMessageResponse, +} from "../actions/app/verifySignInMessage"; import { Client, ClientConfig, createClient } from "./createClient"; -import { AsyncHttpResponse } from "./transports/http"; export interface AppClient extends Client { - connect: (args: ConnectArgs) => AsyncHttpResponse; - status: (args: StatusArgs) => AsyncHttpResponse; + connect: (args: ConnectArgs) => ConnectResponse; + status: (args: StatusArgs) => StatusResponse; + verifySignInMessage: (args: VerifySignInMessageArgs) => VerifySignInMessageResponse; } export const createAppClient = (config: ClientConfig): AppClient => { @@ -14,5 +19,6 @@ export const createAppClient = (config: ClientConfig): AppClient => { ...client, connect: (args: ConnectArgs) => connect(client, args), status: (args: StatusArgs) => status(client, args), + verifySignInMessage: (args: VerifySignInMessageArgs) => verifySignInMessage(client, args), }; }; diff --git a/packages/connect/src/clients/createAuthClient.ts b/packages/connect/src/clients/createAuthClient.ts index 73ea9be..d6fdfee 100644 --- a/packages/connect/src/clients/createAuthClient.ts +++ b/packages/connect/src/clients/createAuthClient.ts @@ -1,9 +1,14 @@ -import { authenticate, AuthenticateArgs, AuthenticateResponse } from "../actions/authenticate"; +import { authenticate, AuthenticateArgs, AuthenticateResponse } from "../actions/auth/authenticate"; +import { + buildSignInMessage, + BuildSignInMessageArgs, + BuildSignInMessageResponse, +} from "../actions/auth/buildSignInMessage"; import { Client, ClientConfig, createClient } from "./createClient"; -import { AsyncHttpResponse } from "./transports/http"; export interface AuthClient extends Client { - authenticate: (args: AuthenticateArgs) => AsyncHttpResponse; + authenticate: (args: AuthenticateArgs) => AuthenticateResponse; + buildSignInMessage: (args: BuildSignInMessageArgs) => BuildSignInMessageResponse; } export const createAuthClient = (config: ClientConfig): AuthClient => { @@ -11,5 +16,6 @@ export const createAuthClient = (config: ClientConfig): AuthClient => { return { ...client, authenticate: (args: AuthenticateArgs) => authenticate(client, args), + buildSignInMessage: (args: BuildSignInMessageArgs) => buildSignInMessage(client, args), }; }; diff --git a/packages/connect/src/clients/index.ts b/packages/connect/src/clients/index.ts index 81fcda3..6c1af6b 100644 --- a/packages/connect/src/clients/index.ts +++ b/packages/connect/src/clients/index.ts @@ -4,10 +4,10 @@ export { createAuthClient } from "./createAuthClient"; export type { AppClient } from "./createAppClient"; export type { AuthClient } from "./createAuthClient"; export type { ClientConfig } from "./createClient"; -export type { ConnectArgs, ConnectResponse } from "../actions/connect"; -export type { StatusArgs, StatusResponse } from "../actions/status"; +export type { ConnectArgs, ConnectResponse } from "../actions/app/connect"; +export type { StatusArgs, StatusResponse } from "../actions/app/status"; export type { AuthenticateArgs, AuthenticateResponse, -} from "../actions/authenticate"; +} from "../actions/auth/authenticate"; export type { AsyncHttpResponse, HttpResponse } from "./transports/http"; diff --git a/packages/connect/src/index.ts b/packages/connect/src/index.ts index f9e5d5d..07aec75 100644 --- a/packages/connect/src/index.ts +++ b/packages/connect/src/index.ts @@ -1,3 +1,2 @@ export * from "./errors"; -export * from "./messages"; export * from "./clients"; diff --git a/packages/connect/src/messages/build.test.ts b/packages/connect/src/messages/build.test.ts new file mode 100644 index 0000000..a5e77bb --- /dev/null +++ b/packages/connect/src/messages/build.test.ts @@ -0,0 +1,41 @@ +import { build } from "./build"; + +const siweParams = { + domain: "example.com", + address: "0x63C378DDC446DFf1d831B9B96F7d338FE6bd4231", + uri: "https://example.com/login", + version: "1", + nonce: "12345678", + issuedAt: "2023-10-01T00:00:00.000Z", +}; + +describe("build", () => { + test("adds connect-specific parameters", () => { + const result = build({ + ...siweParams, + fid: 5678, + }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toMatchObject({ + ...siweParams, + statement: "Farcaster Connect", + chainId: 10, + resources: ["farcaster://fid/5678"], + }); + }); + + test("handles additional resources", () => { + const result = build({ + ...siweParams, + fid: 5678, + resources: ["https://example.com/resource"], + }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toMatchObject({ + ...siweParams, + statement: "Farcaster Connect", + chainId: 10, + resources: ["farcaster://fid/5678", "https://example.com/resource"], + }); + }); +}); diff --git a/packages/connect/src/messages/build.ts b/packages/connect/src/messages/build.ts new file mode 100644 index 0000000..08dd22d --- /dev/null +++ b/packages/connect/src/messages/build.ts @@ -0,0 +1,22 @@ +import { SiweMessage } from "siwe"; +import { ConnectResult } from "../errors"; +import { validate } from "./validate"; +import { STATEMENT, CHAIN_ID } from "./constants"; + +export type FarcasterResourceParams = { + fid: number; +}; +export type SignInMessageParams = Partial & FarcasterResourceParams; + +export const build = (params: SignInMessageParams): ConnectResult => { + const { fid, ...siweParams } = params; + const resources = siweParams.resources ?? []; + siweParams.statement = STATEMENT; + siweParams.chainId = CHAIN_ID; + siweParams.resources = [buildFidResource(fid), ...resources]; + return validate(siweParams); +}; + +const buildFidResource = (fid: number): string => { + return `farcaster://fid/${fid}`; +}; diff --git a/packages/connect/src/messages/constants.ts b/packages/connect/src/messages/constants.ts new file mode 100644 index 0000000..04d1b4b --- /dev/null +++ b/packages/connect/src/messages/constants.ts @@ -0,0 +1,2 @@ +export const STATEMENT = "Farcaster Connect"; +export const CHAIN_ID = 10; diff --git a/packages/connect/src/messages/index.ts b/packages/connect/src/messages/index.ts deleted file mode 100644 index b82b741..0000000 --- a/packages/connect/src/messages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as messages from "./messages"; diff --git a/packages/connect/src/messages/messages.test.ts b/packages/connect/src/messages/messages.test.ts deleted file mode 100644 index a1c4b25..0000000 --- a/packages/connect/src/messages/messages.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { validate, parseFid, verify, build } from "./messages"; -import { ConnectError } from "../errors"; -import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { Hex, zeroAddress } from "viem"; -import { getDefaultProvider } from "ethers"; -import { SiweMessage } from "siwe"; -import fs from "fs"; - -const account = privateKeyToAccount(generatePrivateKey()); - -const siweParams = { - domain: "example.com", - address: "0x63C378DDC446DFf1d831B9B96F7d338FE6bd4231", - uri: "https://example.com/login", - version: "1", - nonce: "12345678", - issuedAt: "2023-10-01T00:00:00.000Z", -}; - -const connectParams = { - ...siweParams, - statement: "Farcaster Connect", - chainId: 10, - resources: ["farcaster://fid/1234"], -}; - -describe("build", () => { - test("adds connect-specific parameters", () => { - const result = build({ - ...siweParams, - fid: 5678, - userDataParams: ["pfp", "display", "username"], - }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toMatchObject({ - ...siweParams, - statement: "Farcaster Connect", - chainId: 10, - resources: ["farcaster://fid/5678", "farcaster://fid/5678/userdata?pfp&display&username"], - }); - }); - - test("handles empty userData", () => { - const result = build({ - ...siweParams, - fid: 5678, - }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toMatchObject({ - ...siweParams, - statement: "Farcaster Connect", - chainId: 10, - resources: ["farcaster://fid/5678"], - }); - }); - - test("handles additional resources", () => { - const result = build({ - ...siweParams, - fid: 5678, - resources: ["https://example.com/resource"], - }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toMatchObject({ - ...siweParams, - statement: "Farcaster Connect", - chainId: 10, - resources: ["farcaster://fid/5678", "https://example.com/resource"], - }); - }); -}); - -describe("validate", () => { - test("default parameters are valid", () => { - const result = validate(connectParams); - expect(result.isOk()).toBe(true); - }); - - test("propagates SIWE message errors", () => { - const result = validate({ - ...connectParams, - address: "Invalid address", - }); - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().errCode).toEqual("bad_request.validation_failure"); - expect(result._unsafeUnwrapErr().message).toMatch("invalid address"); - }); - - test("message must contain 'Farcaster Connect'", () => { - const result = validate({ - ...connectParams, - statement: "Invalid statement", - }); - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual( - new ConnectError("bad_request.validation_failure", "Statement must be 'Farcaster Connect'"), - ); - }); - - test("message must include chainId 10", () => { - const result = validate({ - ...connectParams, - chainId: 1, - }); - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual( - new ConnectError("bad_request.validation_failure", "Chain ID must be 10"), - ); - }); - - test("message must include FID resource", () => { - const result = validate({ - ...connectParams, - resources: [], - }); - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual( - new ConnectError("bad_request.validation_failure", "No fid resource provided"), - ); - }); - - test("message must only include one FID resource", () => { - const result = validate({ - ...connectParams, - resources: ["farcaster://fid/1", "farcaster://fid/2"], - }); - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual( - new ConnectError("bad_request.validation_failure", "Multiple fid resources provided"), - ); - }); -}); - -describe("parseFid", () => { - test("parses fid from valid message", () => { - const message = validate({ - ...connectParams, - resources: ["farcaster://fid/42"], - }); - const result = parseFid(message._unsafeUnwrap()); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toEqual(42); - }); -}); - -describe("verify", () => { - test("verifies valid EOA signatures", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(1234n); - - const res = build({ - ...siweParams, - address: account.address, - fid: 1234, - }); - const message = res._unsafeUnwrap(); - const sig = await account.signMessage({ message: message.toMessage() }); - fs.writeFileSync("sig.json", JSON.stringify({ message: message.toMessage(), sig: sig })); - const result = await verify(message, sig, { fidVerifier }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toStrictEqual({ - data: message, - success: true, - fid: 1234, - }); - }); - - test("adds parsed resources to response", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(1234n); - - const res = build({ - ...siweParams, - address: account.address, - fid: 1234, - userDataParams: ["bio", "pfp", "display"], - }); - const message = res._unsafeUnwrap(); - const sig = await account.signMessage({ message: message.toMessage() }); - const result = await verify(message, sig, { fidVerifier }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toStrictEqual({ - data: message, - success: true, - fid: 1234, - userDataParams: ["bio", "pfp", "display"], - }); - }); - - test("omits mismatched userdata", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(1234n); - - const message = new SiweMessage({ - ...connectParams, - address: account.address, - resources: ["farcaster://fid/1234", "farcaster://fid/5678/userdata?pfp&display&username"], - }); - const sig = await account.signMessage({ message: message.toMessage() }); - const result = await verify(message, sig, { fidVerifier }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toStrictEqual({ - data: message, - success: true, - fid: 1234, - }); - }); - - test("omits userdata with invalid parameters", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(1234n); - - const message = new SiweMessage({ - ...connectParams, - address: account.address, - resources: ["farcaster://fid/1234", "farcaster://fid/1234/userdata?invalid¶m"], - }); - const sig = await account.signMessage({ message: message.toMessage() }); - const result = await verify(message, sig, { fidVerifier }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toStrictEqual({ - data: message, - success: true, - fid: 1234, - }); - }); - - test("verifies valid 1271 signatures", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(1234n); - const provider = getDefaultProvider(10); - - const res = build({ - ...siweParams, - address: "0xC89858205c6AdDAD842E1F58eD6c42452671885A", - fid: 1234, - }); - const message = res._unsafeUnwrap(); - const sig = await account.signMessage({ message: message.toMessage() }); - const result = await verify(message, sig, { fidVerifier, provider }); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toStrictEqual({ - data: message, - success: true, - fid: 1234, - }); - }); - - test("1271 signatures fail without provider", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(1234n); - - const res = build({ - ...siweParams, - address: "0xC89858205c6AdDAD842E1F58eD6c42452671885A", - fid: 1234, - }); - const message = res._unsafeUnwrap(); - const sig = await account.signMessage({ message: message.toMessage() }); - const result = await verify(message, sig, { fidVerifier }); - expect(result.isOk()).toBe(false); - const err = result._unsafeUnwrapErr(); - expect(err.errCode).toBe("unauthorized"); - expect(err.message).toBe("Signature does not match address of the message."); - }); - - test("invalid SIWE message", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(1234n); - - const message = build({ - ...siweParams, - address: zeroAddress, - fid: 1234, - }); - const sig = await account.signMessage({ - message: message._unsafeUnwrap().toMessage(), - }); - const result = await verify(message._unsafeUnwrap(), sig, { fidVerifier }); - expect(result.isOk()).toBe(false); - const err = result._unsafeUnwrapErr(); - expect(err.errCode).toBe("unauthorized"); - expect(err.message).toBe("Signature does not match address of the message."); - }); - - test("invalid fid owner", async () => { - const fidVerifier = (_custody: Hex) => Promise.resolve(5678n); - - const message = build({ - ...siweParams, - address: account.address, - fid: 1234, - }); - const sig = await account.signMessage({ - message: message._unsafeUnwrap().toMessage(), - }); - const result = await verify(message._unsafeUnwrap(), sig, { fidVerifier }); - expect(result.isOk()).toBe(false); - const err = result._unsafeUnwrapErr(); - expect(err.errCode).toBe("unauthorized"); - expect(err.message).toBe(`Invalid resource: signer ${account.address} does not own fid 1234.`); - }); - - test("client error", async () => { - const fidVerifier = (_custody: Hex) => Promise.reject(new Error("client error")); - - const message = build({ - ...siweParams, - address: account.address, - fid: 1234, - }); - const sig = await account.signMessage({ - message: message._unsafeUnwrap().toMessage(), - }); - const result = await verify(message._unsafeUnwrap(), sig, { fidVerifier }); - expect(result.isOk()).toBe(false); - const err = result._unsafeUnwrapErr(); - expect(err.errCode).toBe("unavailable"); - expect(err.message).toBe("client error"); - }); - - test("missing verifier", async () => { - const message = build({ - ...siweParams, - address: account.address, - fid: 1234, - }); - const sig = await account.signMessage({ - message: message._unsafeUnwrap().toMessage(), - }); - const result = await verify(message._unsafeUnwrap(), sig); - expect(result.isOk()).toBe(false); - const err = result._unsafeUnwrapErr(); - expect(err.errCode).toBe("unavailable"); - expect(err.message).toBe("Not implemented: Must provide an fid verifier"); - }); -}); diff --git a/packages/connect/src/messages/messages.ts b/packages/connect/src/messages/messages.ts deleted file mode 100644 index 6414e95..0000000 --- a/packages/connect/src/messages/messages.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { SiweMessage, SiweResponse, SiweError } from "siwe"; -import { Result, ResultAsync, err, ok } from "neverthrow"; -import { Provider } from "ethers"; -import { ConnectResult, ConnectAsyncResult, ConnectError } from "../errors"; - -type Hex = `0x${string}`; -type UserDataTypeParam = "pfp" | "display" | "bio" | "url" | "username"; -type ConnectResourceParams = { - fid: number; - userDataParams?: UserDataTypeParam[]; -}; -type ConnectParams = Partial & ConnectResourceParams; -type ConnectOpts = { - fidVerifier: (custody: Hex) => Promise; - provider?: Provider; -}; - -type ConnectResponse = SiweResponse & ConnectResourceParams; - -const FID_URI_REGEX = /^farcaster:\/\/fid\/([1-9]\d*)\/?$/; -const USER_DATA_URI_REGEX = - /^farcaster:\/\/fid\/([1-9]\d*)\/userdata\?((pfp|display|bio|url|username)(?:&(pfp|display|bio|url|username))*)$/; -const STATEMENT = "Farcaster Connect"; -const CHAIN_ID = 10; - -const voidFidVerifier = (_custody: Hex) => Promise.reject(new Error("Not implemented: Must provide an fid verifier")); - -/** - * Build a Farcaster Connect message from the provided parameters. Message - * parameters are a superset of SIWE message parameters, plus fid and requested - * userData fields. - */ -export const build = (params: ConnectParams): ConnectResult => { - const { fid, userDataParams, ...siweParams } = params; - const resources = siweParams.resources ?? []; - siweParams.statement = STATEMENT; - siweParams.chainId = CHAIN_ID; - siweParams.resources = [...buildResources(fid, userDataParams), ...resources]; - return validate(siweParams); -}; - -/** - * Verify signature of a Farcaster Connect message. Returns an error if the - * message is invalid or the signature is invalid. - */ -export const verify = async ( - message: string | Partial, - signature: string, - options: ConnectOpts = { - fidVerifier: voidFidVerifier, - }, -): ConnectAsyncResult => { - const { fidVerifier, provider } = options; - const valid = validate(message); - if (valid.isErr()) return err(valid.error); - - const siwe = (await verifySiweMessage(valid.value, signature, provider)).andThen(mergeResources); - if (siwe.isErr()) return err(siwe.error); - if (!siwe.value.success) { - const errMessage = siwe.value.error?.type ?? "Unknown error"; - return err(new ConnectError("unauthorized", errMessage)); - } - - const fid = await verifyFidOwner(siwe.value, fidVerifier); - if (fid.isErr()) return err(fid.error); - if (!fid.value.success) { - const errMessage = siwe.value.error?.type ?? "Unknown error"; - return err(new ConnectError("unauthorized", errMessage)); - } - return ok(fid.value); -}; - -/** - * Validate a Farcaster Connect message. Checks that the message is a valid - * Farcaster Connect and SIWE message, but does not verify the signature. - * Use verify for message authentication. - */ -export const validate = (params: string | Partial): ConnectResult => { - return Result.fromThrowable( - // SiweMessage validates itself when constructed - () => new SiweMessage(params), - // If construction time validation fails, propagate the error - (e) => new ConnectError("bad_request.validation_failure", e as Error), - )() - .andThen(validateStatement) - .andThen(validateChainId) - .andThen(validateResources); -}; - -/** - * Parse fid and UserData resources from a Farcaster Connect message. - */ -export const parseResources = (message: SiweMessage): ConnectResult => { - const fid = parseFid(message); - if (fid.isErr()) return err(fid.error); - - const userDataParams = parseUserData(message); - if (userDataParams.isErr()) return err(userDataParams.error); - - if (userDataParams.value) { - if (userDataParams.value.fid === fid.value) { - return ok(userDataParams.value); - } - } - return ok({ fid: fid.value }); -}; - -/** - * Parse associated fid resource from a Farcaster Connect message. - */ -export const parseFid = (message: SiweMessage): ConnectResult => { - const resource = (message.resources ?? []).find((resource) => { - return FID_URI_REGEX.test(resource); - }); - if (!resource) { - return err(new ConnectError("bad_request.validation_failure", "No fid resource provided")); - } - const fid = parseInt(resource.match(FID_URI_REGEX)?.[1] ?? ""); - if (isNaN(fid)) { - return err(new ConnectError("bad_request.validation_failure", "Invalid fid")); - } - return ok(fid); -}; - -/** - * Parse associated UserData resource from a Farcaster Connect message. - */ -export const parseUserData = (message: SiweMessage): ConnectResult => { - const resource = (message.resources ?? []).find((resource) => { - return USER_DATA_URI_REGEX.test(resource); - }); - if (resource) { - const fid = parseInt(resource.match(USER_DATA_URI_REGEX)?.[1] ?? ""); - const userDataParams = parseUserDataResources(resource.match(USER_DATA_URI_REGEX)?.[2] ?? ""); - if (isNaN(fid)) { - return err(new ConnectError("bad_request.validation_failure", "Invalid fid")); - } - return ok({ fid, userDataParams }); - } else { - return ok(undefined); - } -}; - -/** - * Validate a Farcaster Connect message's statement. The statement must be - * "Farcaster Connect". - */ -export const validateStatement = (message: SiweMessage): ConnectResult => { - if (message.statement !== STATEMENT) { - return err(new ConnectError("bad_request.validation_failure", `Statement must be '${STATEMENT}'`)); - } - return ok(message); -}; - -/** - * Validate a Farcaster Connect message's chain ID. The chain ID must be 10. - */ -export const validateChainId = (message: SiweMessage): ConnectResult => { - if (message.chainId !== CHAIN_ID) { - return err(new ConnectError("bad_request.validation_failure", `Chain ID must be ${CHAIN_ID}`)); - } - return ok(message); -}; - -/** - * Validate a Farcaster Connect message's resources. The message must contain a - * single fid resource, e.g. "farcaster://fid/123". - */ -export const validateResources = (message: SiweMessage): ConnectResult => { - const fidResources = (message.resources ?? []).filter((resource) => { - return FID_URI_REGEX.test(resource); - }); - if (fidResources.length === 0) { - return err(new ConnectError("bad_request.validation_failure", "No fid resource provided")); - } else if (fidResources.length > 1) { - return err(new ConnectError("bad_request.validation_failure", "Multiple fid resources provided")); - } else { - return ok(message); - } -}; - -const verifySiweMessage = async ( - message: SiweMessage, - signature: string, - provider?: Provider, -): ConnectAsyncResult => { - return ResultAsync.fromPromise(message.verify({ signature }, { provider, suppressExceptions: true }), (e) => { - return new ConnectError("unauthorized", e as Error); - }); -}; - -const mergeResources = (response: SiweResponse): ConnectResult => { - return parseResources(response.data).andThen((resources) => { - return ok({ ...resources, ...response }); - }); -}; - -const verifyFidOwner = async ( - response: ConnectResponse, - fidVerifier: (custody: Hex) => Promise, -): ConnectAsyncResult => { - const signer = response.data.address as Hex; - return ResultAsync.fromPromise(fidVerifier(signer), (e) => { - return new ConnectError("unavailable", e as Error); - }).andThen((fid) => { - if (fid !== BigInt(response.fid)) { - response.success = false; - response.error = new SiweError( - `Invalid resource: signer ${signer} does not own fid ${response.fid}.`, - response.fid.toString(), - fid.toString(), - ); - } - return ok(response); - }); -}; - -const buildResources = (fid: number, userDataParams: UserDataTypeParam[] | undefined): string[] => { - const userData = userDataParams ?? []; - if (userData.length === 0) return [buildFidResource(fid)]; - return [buildFidResource(fid), buildUserDataResource(fid, userData)]; -}; - -const buildFidResource = (fid: number): string => { - return `farcaster://fid/${fid}`; -}; - -const buildUserDataResource = (fid: number, userData: UserDataTypeParam[]): string => { - const params = Array.from(new Set(userData)).join("&"); - return `farcaster://fid/${fid}/userdata?${params}`; -}; - -const parseUserDataResources = (queryString: string) => { - const isUserDataParam = (param: string): param is UserDataTypeParam => - ["pfp", "display", "bio", "url", "username"].includes(param); - return queryString.split("&").filter(isUserDataParam); -}; diff --git a/packages/connect/src/messages/validate.test.ts b/packages/connect/src/messages/validate.test.ts new file mode 100644 index 0000000..fbef1a5 --- /dev/null +++ b/packages/connect/src/messages/validate.test.ts @@ -0,0 +1,79 @@ +import { validate } from "./validate"; +import { ConnectError } from "../errors"; + +const siweParams = { + domain: "example.com", + address: "0x63C378DDC446DFf1d831B9B96F7d338FE6bd4231", + uri: "https://example.com/login", + version: "1", + nonce: "12345678", + issuedAt: "2023-10-01T00:00:00.000Z", +}; + +const connectParams = { + ...siweParams, + statement: "Farcaster Connect", + chainId: 10, + resources: ["farcaster://fid/1234"], +}; + +describe("validate", () => { + test("default parameters are valid", () => { + const result = validate(connectParams); + expect(result.isOk()).toBe(true); + }); + + test("propagates SIWE message errors", () => { + const result = validate({ + ...connectParams, + address: "Invalid address", + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().errCode).toEqual("bad_request.validation_failure"); + expect(result._unsafeUnwrapErr().message).toMatch("invalid address"); + }); + + test("message must contain 'Farcaster Connect'", () => { + const result = validate({ + ...connectParams, + statement: "Invalid statement", + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual( + new ConnectError("bad_request.validation_failure", "Statement must be 'Farcaster Connect'"), + ); + }); + + test("message must include chainId 10", () => { + const result = validate({ + ...connectParams, + chainId: 1, + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual( + new ConnectError("bad_request.validation_failure", "Chain ID must be 10"), + ); + }); + + test("message must include FID resource", () => { + const result = validate({ + ...connectParams, + resources: [], + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual( + new ConnectError("bad_request.validation_failure", "No fid resource provided"), + ); + }); + + test("message must only include one FID resource", () => { + const result = validate({ + ...connectParams, + resources: ["farcaster://fid/1", "farcaster://fid/2"], + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual( + new ConnectError("bad_request.validation_failure", "Multiple fid resources provided"), + ); + }); +}); diff --git a/packages/connect/src/messages/validate.ts b/packages/connect/src/messages/validate.ts new file mode 100644 index 0000000..be14d9f --- /dev/null +++ b/packages/connect/src/messages/validate.ts @@ -0,0 +1,66 @@ +import { SiweMessage } from "siwe"; +import { Result, err, ok } from "neverthrow"; +import { ConnectResult, ConnectError } from "../errors"; +import { STATEMENT, CHAIN_ID } from "./constants"; +import { FarcasterResourceParams } from "./build"; + +const FID_URI_REGEX = /^farcaster:\/\/fid\/([1-9]\d*)\/?$/; + +export const validate = (params: string | Partial): ConnectResult => { + return Result.fromThrowable( + // SiweMessage validates itself when constructed + () => new SiweMessage(params), + // If construction time validation fails, propagate the error + (e) => new ConnectError("bad_request.validation_failure", e as Error), + )() + .andThen(validateStatement) + .andThen(validateChainId) + .andThen(validateResources); +}; + +export const parseResources = (message: SiweMessage): ConnectResult => { + const fid = parseFid(message); + if (fid.isErr()) return err(fid.error); + return ok({ fid: fid.value }); +}; + +export const parseFid = (message: SiweMessage): ConnectResult => { + const resource = (message.resources ?? []).find((resource) => { + return FID_URI_REGEX.test(resource); + }); + if (!resource) { + return err(new ConnectError("bad_request.validation_failure", "No fid resource provided")); + } + const fid = parseInt(resource.match(FID_URI_REGEX)?.[1] ?? ""); + if (isNaN(fid)) { + return err(new ConnectError("bad_request.validation_failure", "Invalid fid")); + } + return ok(fid); +}; + +export const validateStatement = (message: SiweMessage): ConnectResult => { + if (message.statement !== STATEMENT) { + return err(new ConnectError("bad_request.validation_failure", `Statement must be '${STATEMENT}'`)); + } + return ok(message); +}; + +export const validateChainId = (message: SiweMessage): ConnectResult => { + if (message.chainId !== CHAIN_ID) { + return err(new ConnectError("bad_request.validation_failure", `Chain ID must be ${CHAIN_ID}`)); + } + return ok(message); +}; + +export const validateResources = (message: SiweMessage): ConnectResult => { + const fidResources = (message.resources ?? []).filter((resource) => { + return FID_URI_REGEX.test(resource); + }); + if (fidResources.length === 0) { + return err(new ConnectError("bad_request.validation_failure", "No fid resource provided")); + } else if (fidResources.length > 1) { + return err(new ConnectError("bad_request.validation_failure", "Multiple fid resources provided")); + } else { + return ok(message); + } +}; diff --git a/packages/connect/src/messages/verify.test.ts b/packages/connect/src/messages/verify.test.ts new file mode 100644 index 0000000..9043371 --- /dev/null +++ b/packages/connect/src/messages/verify.test.ts @@ -0,0 +1,173 @@ +import { build } from "./build"; +import { verify } from "./verify"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { Hex, zeroAddress } from "viem"; +import { getDefaultProvider } from "ethers"; +import { SiweMessage } from "siwe"; +import fs from "fs"; + +const account = privateKeyToAccount(generatePrivateKey()); + +const siweParams = { + domain: "example.com", + address: "0x63C378DDC446DFf1d831B9B96F7d338FE6bd4231", + uri: "https://example.com/login", + version: "1", + nonce: "12345678", + issuedAt: "2023-10-01T00:00:00.000Z", +}; + +const connectParams = { + ...siweParams, + statement: "Farcaster Connect", + chainId: 10, + resources: ["farcaster://fid/1234"], +}; + +describe("verify", () => { + test("verifies valid EOA signatures", async () => { + const verifyFid = (_custody: Hex) => Promise.resolve(1234n); + + const res = build({ + ...siweParams, + address: account.address, + fid: 1234, + }); + const message = res._unsafeUnwrap(); + const sig = await account.signMessage({ message: message.toMessage() }); + fs.writeFileSync("sig.json", JSON.stringify({ message: message.toMessage(), sig: sig })); + const result = await verify(message, sig, { verifyFid }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toStrictEqual({ + data: message, + success: true, + fid: 1234, + }); + }); + + test("adds parsed resources to response", async () => { + const verifyFid = (_custody: Hex) => Promise.resolve(1234n); + + const res = build({ + ...siweParams, + address: account.address, + fid: 1234, + }); + const message = res._unsafeUnwrap(); + const sig = await account.signMessage({ message: message.toMessage() }); + const result = await verify(message, sig, { verifyFid }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toStrictEqual({ + data: message, + success: true, + fid: 1234, + }); + }); + + test("verifies valid 1271 signatures", async () => { + const verifyFid = (_custody: Hex) => Promise.resolve(1234n); + const provider = getDefaultProvider(10); + + const res = build({ + ...siweParams, + address: "0xC89858205c6AdDAD842E1F58eD6c42452671885A", + fid: 1234, + }); + const message = res._unsafeUnwrap(); + const sig = await account.signMessage({ message: message.toMessage() }); + const result = await verify(message, sig, { verifyFid, provider }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toStrictEqual({ + data: message, + success: true, + fid: 1234, + }); + }); + + test("1271 signatures fail without provider", async () => { + const verifyFid = (_custody: Hex) => Promise.resolve(1234n); + + const res = build({ + ...siweParams, + address: "0xC89858205c6AdDAD842E1F58eD6c42452671885A", + fid: 1234, + }); + const message = res._unsafeUnwrap(); + const sig = await account.signMessage({ message: message.toMessage() }); + const result = await verify(message, sig, { verifyFid }); + expect(result.isOk()).toBe(false); + const err = result._unsafeUnwrapErr(); + expect(err.errCode).toBe("unauthorized"); + expect(err.message).toBe("Signature does not match address of the message."); + }); + + test("invalid SIWE message", async () => { + const verifyFid = (_custody: Hex) => Promise.resolve(1234n); + + const message = build({ + ...siweParams, + address: zeroAddress, + fid: 1234, + }); + const sig = await account.signMessage({ + message: message._unsafeUnwrap().toMessage(), + }); + const result = await verify(message._unsafeUnwrap(), sig, { verifyFid }); + expect(result.isOk()).toBe(false); + const err = result._unsafeUnwrapErr(); + expect(err.errCode).toBe("unauthorized"); + expect(err.message).toBe("Signature does not match address of the message."); + }); + + test("invalid fid owner", async () => { + const verifyFid = (_custody: Hex) => Promise.resolve(5678n); + + const message = build({ + ...siweParams, + address: account.address, + fid: 1234, + }); + const sig = await account.signMessage({ + message: message._unsafeUnwrap().toMessage(), + }); + const result = await verify(message._unsafeUnwrap(), sig, { verifyFid }); + expect(result.isOk()).toBe(false); + const err = result._unsafeUnwrapErr(); + expect(err.errCode).toBe("unauthorized"); + expect(err.message).toBe(`Invalid resource: signer ${account.address} does not own fid 1234.`); + }); + + test("client error", async () => { + const verifyFid = (_custody: Hex) => Promise.reject(new Error("client error")); + + const message = build({ + ...siweParams, + address: account.address, + fid: 1234, + }); + const sig = await account.signMessage({ + message: message._unsafeUnwrap().toMessage(), + }); + const result = await verify(message._unsafeUnwrap(), sig, { verifyFid }); + expect(result.isOk()).toBe(false); + const err = result._unsafeUnwrapErr(); + expect(err.errCode).toBe("unavailable"); + expect(err.message).toBe("client error"); + }); + + test("missing verifier", async () => { + const message = build({ + ...siweParams, + address: account.address, + fid: 1234, + }); + const sig = await account.signMessage({ + message: message._unsafeUnwrap().toMessage(), + }); + const result = await verify(message._unsafeUnwrap(), sig); + expect(result.isOk()).toBe(false); + const err = result._unsafeUnwrapErr(); + expect(err.errCode).toBe("unavailable"); + expect(err.message).toBe("Not implemented: Must provide an fid verifier"); + }); +}); diff --git a/packages/connect/src/messages/verify.ts b/packages/connect/src/messages/verify.ts new file mode 100644 index 0000000..45e06cb --- /dev/null +++ b/packages/connect/src/messages/verify.ts @@ -0,0 +1,83 @@ +import { SiweMessage, SiweResponse, SiweError } from "siwe"; +import { ResultAsync, err, ok } from "neverthrow"; +import { Provider } from "ethers"; +import { ConnectAsyncResult, ConnectResult, ConnectError } from "../errors"; + +import { validate, parseResources } from "./validate"; +import { FarcasterResourceParams } from "./build"; + +type Hex = `0x${string}`; +type SignInOpts = { + verifyFid: (custody: Hex) => Promise; + provider?: Provider; +}; +export type SignInResponse = SiweResponse & FarcasterResourceParams; + +const voidVerifyFid = (_custody: Hex) => Promise.reject(new Error("Not implemented: Must provide an fid verifier")); + +/** + * Verify signature of a Farcaster Connect message. Returns an error if the + * message is invalid or the signature is invalid. + */ +export const verify = async ( + message: string | Partial, + signature: string, + options: SignInOpts = { + verifyFid: voidVerifyFid, + }, +): ConnectAsyncResult => { + const { verifyFid, provider } = options; + const valid = validate(message); + if (valid.isErr()) return err(valid.error); + + const siwe = (await verifySiweMessage(valid.value, signature, provider)).andThen(mergeResources); + if (siwe.isErr()) return err(siwe.error); + if (!siwe.value.success) { + const errMessage = siwe.value.error?.type ?? "Unknown error"; + return err(new ConnectError("unauthorized", errMessage)); + } + + const fid = await verifyFidOwner(siwe.value, verifyFid); + if (fid.isErr()) return err(fid.error); + if (!fid.value.success) { + const errMessage = siwe.value.error?.type ?? "Unknown error"; + return err(new ConnectError("unauthorized", errMessage)); + } + return ok(fid.value); +}; + +const verifySiweMessage = async ( + message: SiweMessage, + signature: string, + provider?: Provider, +): ConnectAsyncResult => { + return ResultAsync.fromPromise(message.verify({ signature }, { provider, suppressExceptions: true }), (e) => { + return new ConnectError("unauthorized", e as Error); + }); +}; + +const verifyFidOwner = async ( + response: SignInResponse, + fidVerifier: (custody: Hex) => Promise, +): ConnectAsyncResult => { + const signer = response.data.address as Hex; + return ResultAsync.fromPromise(fidVerifier(signer), (e) => { + return new ConnectError("unavailable", e as Error); + }).andThen((fid) => { + if (fid !== BigInt(response.fid)) { + response.success = false; + response.error = new SiweError( + `Invalid resource: signer ${signer} does not own fid ${response.fid}.`, + response.fid.toString(), + fid.toString(), + ); + } + return ok(response); + }); +}; + +const mergeResources = (response: SiweResponse): ConnectResult => { + return parseResources(response.data).andThen((resources) => { + return ok({ ...resources, ...response }); + }); +};