Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: parse sign in URI #6

Merged
merged 2 commits into from
Dec 20, 2023
Merged
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
1 change: 1 addition & 0 deletions packages/connect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"prepublishOnly": "yarn run build"
},
"dependencies": {
"@farcaster/hub-web": "^0.7.1",
"siwe": "^2.1.4"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions packages/connect/src/actions/app/connect.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createAppClient } from "../../clients/createAppClient";
import { jest } from "@jest/globals";
import { viem } from "../../clients/ethereum/viem";

describe("connect", () => {
const client = createAppClient({
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
});

afterEach(() => {
Expand Down
2 changes: 2 additions & 0 deletions packages/connect/src/actions/app/status.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createAppClient } from "../../clients/createAppClient";
import { jest } from "@jest/globals";
import { viem } from "../../clients/ethereum/viem";

describe("status", () => {
const client = createAppClient({
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
});

afterEach(() => {
Expand Down
48 changes: 48 additions & 0 deletions packages/connect/src/actions/app/verifySignInMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createAppClient } from "../../clients/createAppClient";
import { createAuthClient } from "../../clients/createAuthClient";
import { viem } from "../../clients/ethereum/viem";
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
import { ConnectError } from "../../errors";

describe("verifySignInMessage", () => {
const client = createAppClient({
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
});

const authClient = createAuthClient({
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
});

const account = privateKeyToAccount(generatePrivateKey());

const siweParams = {
domain: "example.com",
uri: "https://example.com/login",
version: "1",
issuedAt: "2023-10-01T00:00:00.000Z",
};

test("verifies sign in message", async () => {
const message = authClient.buildSignInMessage({
...siweParams,
address: account.address,
fid: 1234,
});

const signature = await account.signMessage({
message: message.toMessage(),
});

const errMsg = `Invalid resource: signer ${account.address} does not own fid 1234.`;
const error = new ConnectError("unauthorized", errMsg);

await expect(
client.verifySignInMessage({
message,
signature,
}),
).rejects.toStrictEqual(error);
});
});
9 changes: 6 additions & 3 deletions packages/connect/src/actions/app/verifySignInMessage.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { SiweMessage } from "siwe";
import { Client } from "../../clients/createClient";
import { SignInResponse, verify } from "../../messages/verify";

export interface VerifySignInMessageArgs {
message: string;
message: string | Partial<SiweMessage>;
signature: `0x${string}`;
}

export type VerifySignInMessageResponse = Promise<SignInResponse>;

export const verifySignInMessage = async (
_client: Client,
client: Client,
{ message, signature }: VerifySignInMessageArgs,
): VerifySignInMessageResponse => {
const result = await verify(message, signature);
const result = await verify(message, signature, {
getFid: client.ethereum.getFid,
});
if (result.isErr()) {
throw result.error;
} else {
Expand Down
2 changes: 2 additions & 0 deletions packages/connect/src/actions/auth/authenticate.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createAuthClient } from "../../clients/createAuthClient";
import { jest } from "@jest/globals";
import { viem } from "../../clients/ethereum/viem";

describe("authenticate", () => {
const client = createAuthClient({
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
});

afterEach(() => {
Expand Down
31 changes: 31 additions & 0 deletions packages/connect/src/actions/auth/buildSignInMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createAuthClient } from "../../clients/createAuthClient";
import { viem } from "../../clients/ethereum/viem";

describe("buildSignInMessage", () => {
const client = createAuthClient({
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
});

test("builds Siwe message from provided parameters", async () => {
const message = client.buildSignInMessage({
address: "0x63C378DDC446DFf1d831B9B96F7d338FE6bd4231",
uri: "https://example.com/login",
domain: "example.com",
nonce: "12345678",
fid: 1,
resources: ["https://example.com/resource"],
});

expect(message).toMatchObject({
address: "0x63C378DDC446DFf1d831B9B96F7d338FE6bd4231",
statement: "Farcaster Connect",
chainId: 10,
uri: "https://example.com/login",
domain: "example.com",
version: "1",
nonce: "12345678",
resources: ["farcaster://fid/1", "https://example.com/resource"],
});
});
});
22 changes: 22 additions & 0 deletions packages/connect/src/actions/auth/parseSignInURI.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createAuthClient } from "../../clients/createAuthClient";
import { viem } from "../../clients/ethereum/viem";

describe("parseSignInURI", () => {
const client = createAuthClient({
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
});

test("parses sign in params from protocol URI", async () => {
const { channelToken, params } = client.parseSignInURI({
uri: "farcaster://connect?channelToken=76be6229-bdf7-4ad2-930a-540fb2de1e08&nonce=ESsxs6MaFio7OvqWb&siweUri=https%3A%2F%2Fexample.com%2Flogin&domain=example.com",
});

expect(channelToken).toBe("76be6229-bdf7-4ad2-930a-540fb2de1e08");
expect(params).toStrictEqual({
domain: "example.com",
siweUri: "https://example.com/login",
nonce: "ESsxs6MaFio7OvqWb",
});
});
});
16 changes: 16 additions & 0 deletions packages/connect/src/actions/auth/parseSignInURI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Client } from "../../clients/createClient";
import { parseSignInURI as parse, ParsedSignInURI } from "../../messages/parseSignInURI";

export interface ParseSignInURIArgs {
uri: string;
}
export type ParseSignInURIResponse = ParsedSignInURI;

export const parseSignInURI = (_client: Client, { uri }: ParseSignInURIArgs): ParseSignInURIResponse => {
const result = parse(uri);
if (result.isErr()) {
throw result.error;
} else {
return result.value;
}
};
2 changes: 2 additions & 0 deletions packages/connect/src/clients/createAppClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createAppClient, AppClient } from "./createAppClient";
import { viem } from "./ethereum/viem";

describe("createAppClient", () => {
const config = {
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
};

let appClient: AppClient;
Expand Down
4 changes: 2 additions & 2 deletions packages/connect/src/clients/createAppClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import {
VerifySignInMessageArgs,
VerifySignInMessageResponse,
} from "../actions/app/verifySignInMessage";
import { Client, ClientConfig, createClient } from "./createClient";
import { Client, CreateClientArgs, createClient } from "./createClient";

export interface AppClient extends Client {
connect: (args: ConnectArgs) => ConnectResponse;
status: (args: StatusArgs) => StatusResponse;
verifySignInMessage: (args: VerifySignInMessageArgs) => VerifySignInMessageResponse;
}

export const createAppClient = (config: ClientConfig): AppClient => {
export const createAppClient = (config: CreateClientArgs): AppClient => {
const client = createClient(config);
return {
...client,
Expand Down
2 changes: 2 additions & 0 deletions packages/connect/src/clients/createAuthClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createAuthClient, AuthClient } from "./createAuthClient";
import { viem } from "./ethereum/viem";

describe("createAuthClient", () => {
const config = {
relayURI: "https://connect.farcaster.xyz",
ethereum: viem(),
};

let authClient: AuthClient;
Expand Down
7 changes: 5 additions & 2 deletions packages/connect/src/clients/createAuthClient.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { authenticate, AuthenticateArgs, AuthenticateResponse } from "../actions/auth/authenticate";
import { parseSignInURI, ParseSignInURIArgs, ParseSignInURIResponse } from "../actions/auth/parseSignInURI";
import {
buildSignInMessage,
BuildSignInMessageArgs,
BuildSignInMessageResponse,
} from "../actions/auth/buildSignInMessage";
import { Client, ClientConfig, createClient } from "./createClient";
import { Client, CreateClientArgs, createClient } from "./createClient";

export interface AuthClient extends Client {
authenticate: (args: AuthenticateArgs) => AuthenticateResponse;
buildSignInMessage: (args: BuildSignInMessageArgs) => BuildSignInMessageResponse;
parseSignInURI: (args: ParseSignInURIArgs) => ParseSignInURIResponse;
}

export const createAuthClient = (config: ClientConfig): AuthClient => {
export const createAuthClient = (config: CreateClientArgs): AuthClient => {
const client = createClient(config);
return {
...client,
authenticate: (args: AuthenticateArgs) => authenticate(client, args),
buildSignInMessage: (args: BuildSignInMessageArgs) => buildSignInMessage(client, args),
parseSignInURI: (args: ParseSignInURIArgs) => parseSignInURI(client, args),
};
};
12 changes: 11 additions & 1 deletion packages/connect/src/clients/createClient.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { Ethereum } from "../clients/ethereum/viem";

export interface CreateClientArgs {
relayURI: string;
version?: string;
ethereum: Ethereum;
}

export interface ClientConfig {
relayURI: string;
version?: string;
}

export interface Client {
config: ClientConfig;
ethereum: Ethereum;
}

const configDefaults = {
version: "v1",
};

export const createClient = (config: ClientConfig) => {
export const createClient = ({ ethereum, ...config }: CreateClientArgs) => {
return {
config: { ...configDefaults, ...config },
ethereum: ethereum,
};
};
31 changes: 31 additions & 0 deletions packages/connect/src/clients/ethereum/viem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Hex, createPublicClient, http } from "viem";
import { optimism } from "viem/chains";
import { ID_REGISTRY_ADDRESS, idRegistryABI } from "@farcaster/hub-web";

export interface Ethereum {
getFid: (custody: Hex) => Promise<BigInt>;
}

interface ViemConfigArgs {
rpcUrl?: string;
}

export const viem = (args?: ViemConfigArgs): Ethereum => {
const publicClient = createPublicClient({
chain: optimism,
transport: http(args?.rpcUrl),
});

const getFid = async (custody: Hex): Promise<BigInt> => {
return publicClient.readContract({
address: ID_REGISTRY_ADDRESS,
abi: idRegistryABI,
functionName: "idOf",
args: [custody],
});
};

return {
getFid,
};
};
6 changes: 6 additions & 0 deletions packages/connect/src/messages/build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SiweMessage } from "siwe";
import { ConnectResult } from "../errors";
import { validate } from "./validate";
import { parseSignInURI } from "./parseSignInURI";
import { STATEMENT, CHAIN_ID } from "./constants";

export type FarcasterResourceParams = {
Expand All @@ -11,12 +12,17 @@ export type SignInMessageParams = Partial<SiweMessage> & FarcasterResourceParams
export const build = (params: SignInMessageParams): ConnectResult<SiweMessage> => {
const { fid, ...siweParams } = params;
const resources = siweParams.resources ?? [];
siweParams.version = "1";
siweParams.statement = STATEMENT;
siweParams.chainId = CHAIN_ID;
siweParams.resources = [buildFidResource(fid), ...resources];
return validate(siweParams);
};

export const buildFromSignInURI = (signInUri: string, fid: number): ConnectResult<SiweMessage> => {
return parseSignInURI(signInUri).andThen(({ params }) => build({ ...params, fid }));
};

const buildFidResource = (fid: number): string => {
return `farcaster://fid/${fid}`;
};
17 changes: 17 additions & 0 deletions packages/connect/src/messages/parseSignInURI.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { parseSignInURI } from "./parseSignInURI";

describe("parseSignInUri", () => {
test("parses protocol handler URI into message params", () => {
const signInUri =
"farcaster://connect?channelToken=76be6229-bdf7-4ad2-930a-540fb2de1e08&nonce=ESsxs6MaFio7OvqWb&siweUri=https%3A%2F%2Fexample.com%2Flogin&domain=example.com";
const result = parseSignInURI(signInUri);
expect(result._unsafeUnwrap()).toStrictEqual({
channelToken: "76be6229-bdf7-4ad2-930a-540fb2de1e08",
params: {
domain: "example.com",
siweUri: "https://example.com/login",
nonce: "ESsxs6MaFio7OvqWb",
},
});
});
});
31 changes: 31 additions & 0 deletions packages/connect/src/messages/parseSignInURI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { err, ok } from "neverthrow";
import { ConnectError, ConnectResult } from "../errors";
import { SignInMessageParams } from "./build";

export interface ParsedSignInURI {
channelToken: string;
params: Partial<SignInMessageParams> & { siweUri?: string };
}

export const parseSignInURI = (signInUri: string): ConnectResult<ParsedSignInURI> => {
const url = new URL(signInUri);
const searchParams = Object.fromEntries(url.searchParams.entries());
const { channelToken, ...params } = searchParams;
if (!channelToken) {
return err(validationFail("No channel token provided"));
}
if (!params["nonce"]) {
return err(validationFail("No nonce provided"));
}
if (!params["siweUri"]) {
return err(validationFail("No SIWE URI provided"));
}
if (!params["domain"]) {
return err(validationFail("No domain provided"));
}
return ok({ channelToken, params });
};

const validationFail = (message: string): ConnectError => {
return new ConnectError("bad_request.validation_failure", message);
};
Loading