Skip to content

Commit 673cf4b

Browse files
authored
Authentication System Overhaul PR (#1607)
1 parent 5314d0c commit 673cf4b

23 files changed

+458
-94
lines changed

App.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
import logger from "./utils/logger";
5151
import { AuthenticateWithPasskeyProvider } from "./features/onboarding/contexts/signup-with-passkey.context";
5252
import { PrivyPlaygroundLandingScreen } from "./features/privy-playground/privy-playground-landing.screen";
53+
import { useLogoutOnJwtRefreshError } from "./features/authentication/useLogoutOnJwtRefreshError";
5354
// import { useAccountsStore } from "./features/multi-inbox/multi-inbox.store";
5455
// import { AuthenticateWithPasskeyProvider } from "./features/onboarding/contexts/signup-with-passkey.context";
5556
// import { PrivyPlaygroundLandingScreen } from "./features/privy-playground/privy-playground-landing.screen";
@@ -120,6 +121,7 @@ const coinbaseUrl = new URL(`https://${config.websiteDomain}/coinbase`);
120121
const App = () => {
121122
const styles = useStyles();
122123
const debugRef = useRef();
124+
useLogoutOnJwtRefreshError();
123125

124126
useEffect(() => {
125127
setupAppAttest();

config/config.types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ export type IConfig = {
5353
privy: IPrivyConfig;
5454
evm: IEvmConfig;
5555
appCheckDebugToken?: string;
56+
reactQueryEncryptionKey: string;
5657
};

config/index.ts

+17
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@ import { isDev, isPreview } from "@/utils/getEnv";
22
import { developmentConfig } from "./development";
33
import { productionConfig } from "./production";
44
import { previewConfig } from "./preview";
5+
import { z } from "zod";
6+
7+
// todo: use zod for env and stop typing in multiple places
8+
// crash on first build step rather than at runtime
9+
// const configSchema = z.object({
10+
// // ... other config variables ...
11+
// SECURE_QUERY_ENCRYPTION_KEY: z.string().min(16),
12+
// });
13+
14+
// // export const Config = {
15+
// // // ... other config variables ...
16+
// // SECURE_QUERY_ENCRYPTION_KEY: process.env
17+
// // .SECURE_QUERY_ENCRYPTION_KEY as string,
18+
// // } satisfies z.infer<typeof configSchema>;
19+
20+
// // // Validate config at startup
21+
// // configSchema.parse(Config);
522

623
export const getConfig = () => {
724
if (isDev) return developmentConfig;

config/shared.ts

+3
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,7 @@ export const shared = {
5757
clientId: process.env.EXPO_PUBLIC_PRIVY_CLIENT_ID,
5858
},
5959
thirdwebClientId: process.env.EXPO_PUBLIC_THIRDWEB_CLIENT_ID,
60+
reactQueryEncryptionKey:
61+
// @ts-expect-error todo fixme
62+
process.env.EXPO_PUBLIC_SECURE_REACT_QUERY_ENCRYPTION_KEY,
6063
} as const satisfies Partial<IConfig>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Platform } from "react-native";
2+
import * as Device from "expo-device";
3+
import { api } from "@/utils/api/api";
4+
import { z } from "zod";
5+
6+
const deviceOSEnum = z.enum(["android", "ios", "web"]);
7+
8+
const createUserResponseSchema = z.object({
9+
id: z.string(),
10+
privyUserId: z.string(),
11+
device: z.object({
12+
id: z.string(),
13+
os: deviceOSEnum,
14+
name: z.string().nullable(),
15+
}),
16+
identity: z.object({
17+
id: z.string(),
18+
privyAddress: z.string(),
19+
xmtpId: z.string().nullable(),
20+
}),
21+
profile: z
22+
.object({
23+
id: z.string(),
24+
name: z.string(),
25+
description: z.string().nullable(),
26+
})
27+
.nullable(),
28+
});
29+
30+
type CreateUserResponse = z.infer<typeof createUserResponseSchema>;
31+
32+
export const createUser = async (args: {
33+
privyUserId: string;
34+
smartContractWalletAddress: string;
35+
inboxId: string;
36+
}): Promise<CreateUserResponse> => {
37+
const { privyUserId, smartContractWalletAddress, inboxId } = args;
38+
39+
const requestData = {
40+
privyUserId,
41+
device: {
42+
os: Platform.OS.toLowerCase(),
43+
name: Device.modelId,
44+
},
45+
identity: {
46+
privyAddress: smartContractWalletAddress,
47+
xmtpId: inboxId,
48+
},
49+
};
50+
51+
const response = await api.post<CreateUserResponse>(
52+
"/api/v1/users",
53+
requestData
54+
);
55+
return createUserResponseSchema.parse(response.data);
56+
};
57+
58+
const fetchJwtResponseSchema = z.object({
59+
jwt: z.string(),
60+
});
61+
62+
type FetchJwtResponse = z.infer<typeof fetchJwtResponseSchema>;
63+
64+
export async function fetchJwt() {
65+
// todo(lustig) look at the endpoint this actually is
66+
// const response = await api.post<FetchJwtResponse>("/api/v1/authenticate");
67+
// return fetchJwtResponseSchema.parse(response.data);
68+
// https://xmtp-labs.slack.com/archives/C07NSHXK693/p1739877738959529
69+
const dummyJwtUntilJwtBackendWorks = "dummyJwtUntilJwtBackendWorks";
70+
return dummyJwtUntilJwtBackendWorks;
71+
}

utils/api/auth.ts features/authentication/authentication.headers.ts

+35-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { tryGetAppCheckToken } from "../appCheck";
1+
import { tryGetAppCheckToken } from "../../utils/appCheck";
22
import { MultiInboxClient } from "@/features/multi-inbox/multi-inbox.client";
33
import {
44
getSafeCurrentSender,
55
useAccountsStore,
66
} from "@/features/multi-inbox/multi-inbox.store";
77
import { MultiInboxClientRestorationStates } from "@/features/multi-inbox/multi-inbox-client.types";
88
import { toHex } from "viem";
9+
import { ensureJwtQueryData } from "./jwt.query";
10+
import { AuthenticationError } from "@/utils/error";
911

1012
export const XMTP_INSTALLATION_ID_HEADER_KEY = "X-XMTP-InstallationId";
1113
export const XMTP_INBOX_ID_HEADER_KEY = "X-XMTP-InboxId";
@@ -22,7 +24,16 @@ export type XmtpApiHeaders = {
2224
[XMTP_SIGNATURE_HEADER_KEY]: string;
2325
};
2426

25-
export async function getConvosApiHeaders(): Promise<XmtpApiHeaders> {
27+
/**
28+
* Returns the headers required to authenticate with the XMTP API.
29+
* As of February 18, 2025, these headers are only required for two endpoints:
30+
* 1. Create User endpoint - Creates a new user account
31+
* 2. Authenticate endpoint - Creates a new JWT for an existing user
32+
*
33+
* Note: We don't use refresh tokens since users are already authenticated via
34+
* Privy passkeys. See authentication.readme.md for more details.
35+
*/
36+
export async function getConvosAuthenticationHeaders(): Promise<XmtpApiHeaders> {
2637
const currentEthereumAddress = getSafeCurrentSender().ethereumAddress;
2738
const areInboxesRestored =
2839
useAccountsStore.getState().multiInboxClientRestorationState ===
@@ -33,19 +44,21 @@ export async function getConvosApiHeaders(): Promise<XmtpApiHeaders> {
3344
});
3445

3546
if (!appCheckToken) {
36-
throw new Error(
47+
throw new AuthenticationError(
3748
"No App Check Token Available. This indicates that we believe the app is not running on an authentic build of our application on a device that has not been tampered with."
3849
);
3950
}
4051

4152
if (!areInboxesRestored) {
42-
throw new Error(
43-
"[getConvosApiHeaders] Inboxes not restored; cannot create headers"
53+
throw new AuthenticationError(
54+
"[getConvosAuthenticationHeaders] Inboxes not restored; cannot create headers"
4455
);
4556
}
4657

4758
if (!inboxClient) {
48-
throw new Error("[getConvosApiHeaders] No inbox client found for account");
59+
throw new AuthenticationError(
60+
"[getConvosAuthenticationHeaders] No inbox client found for account"
61+
);
4962
}
5063

5164
const rawAppCheckTokenSignature = await inboxClient.signWithInstallationKey(
@@ -60,3 +73,19 @@ export async function getConvosApiHeaders(): Promise<XmtpApiHeaders> {
6073
[XMTP_SIGNATURE_HEADER_KEY]: appCheckTokenSignatureHexString,
6174
};
6275
}
76+
77+
/**
78+
* Returns the headers required to authenticate with the API.
79+
* As of February 18, 2025, these headers are only required for two endpoints:
80+
* 1. Create User endpoint - Creates a new user account
81+
* 2. Authenticate endpoint - Creates a new JWT for an existing user
82+
*
83+
* Note: We don't use refresh tokens since users are already authenticated via
84+
* Privy passkeys. See authentication.readme.md for more details.
85+
*/
86+
export async function getConvosAuthenticatedHeaders() {
87+
const jwt = await ensureJwtQueryData();
88+
return {
89+
Authorization: `Bearer ${jwt}`,
90+
};
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Authentication Module
2+
3+
This document provides a high-level overview of the authentication strategy used in our application, explaining the flow involving Firebase AppCheck, XMTP, and Ethereum wallet integration via Privy passkeys, as well as the two primary endpoints for user creation and authentication.
4+
5+
## Loom Recordings
6+
7+
[Convos Authentication Modeling Discussion](https://www.loom.com/share/0a1277074f2a4e989ecfe0141deac359)
8+
Participants: Ry, Michael, Thierry
9+
Date: Friday February 14, 2025
10+
11+
## Overview
12+
13+
Our authentication mechanism involves two primary endpoints:
14+
15+
- **Create User Endpoint**: Creates a new user account. It accepts four header keys and optional profile data.
16+
- **Authenticate Endpoint**: Authenticates an existing user and returns a JSON Web Token (JWT) for subsequent requests.
17+
18+
All other endpoints require the JWT for authentication.
19+
20+
## Authentication Flow
21+
22+
1. **Passkey Authentication via Privy**
23+
24+
- The user logs in using a passkey with Privy
25+
- Successful authentication provides access to a Privy account, which in turn gives access to a smart contract Ethereum wallet
26+
27+
2. **Smart Contract Wallet as Signer**
28+
29+
- The Ethereum wallet obtained from Privy acts as the cryptographic signer
30+
- This wallet is used to create an XMTP inbox
31+
32+
3. **Signing the Firebase AppCheck Token**
33+
34+
- The Firebase AppCheck token is provided by Firebase
35+
- The XMTP inbox on the client side signs the AppCheck token
36+
- The backend then verifies the cryptographic signature
37+
38+
4. **JWT Generation**
39+
- Once verified, the backend issues a JWT
40+
- This JWT is then used to authenticate all subsequent API requests
41+
- There is no refresh token as the persistent cryptographic signer (passkey via Privy) handles re-authentication
42+
43+
## Endpoints
44+
45+
### Create User Endpoint
46+
47+
**Purpose**: Creates a new user account
48+
49+
**Headers** (all four required):
50+
51+
1. **Installation ID**: Identifier for the XMTP inbox, derived from the device
52+
2. **Firebase AppCheck Token**: Provided by Firebase
53+
3. **Signed AppCheck Token**: The Firebase AppCheck token signed by the XMTP inbox (client-side)
54+
4. **XMTP Inbox ID**: The XMTP inbox ID of the user, created during the onboarding flow
55+
56+
**Profile Data** (optional but currently always provided):
57+
58+
- `profile_name`: Must be between 3 and 30 characters
59+
- `avatar_url`: No strict URL validation is enforced at this time
60+
- `description`: Cannot exceed 500 characters
61+
62+
**Response**: Returns the newly created user's ID along with metadata such as createdAt, updatedAt, and the provided profile information
63+
64+
### Authenticate Endpoint
65+
66+
**Purpose**: Authenticates an existing user
67+
68+
**Headers** (same four as above):
69+
70+
- Installation ID
71+
- Firebase AppCheck Token
72+
- Signed AppCheck Token
73+
- Ethereum Wallet
74+
75+
**Response**: Returns a JWT which is used for authenticating all further API calls
76+
77+
## Package Organization
78+
79+
All authentication-related code and documentation are located in the `feature/authentication` directory.
80+
81+
## Additional Notes
82+
83+
### Security
84+
85+
The Firebase AppCheck token is signed on the client side by the XMTP inbox and verified by the backend. The integration with Privy and the smart contract wallet removes the need for refresh tokens, as the persistent signer can always re-authenticate.
86+
87+
### Profile Data Validation
88+
89+
Currently, the profile creation is always provided during the Create User process. In future iterations, if profile creation becomes optional or additional validation (e.g., URL validation for avatars) is required, this documentation and the corresponding code should be updated accordingly.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { AxiosRequestConfig } from "axios";
2+
import {
3+
getConvosAuthenticationHeaders,
4+
getConvosAuthenticatedHeaders,
5+
} from "@/features/authentication/authentication.headers";
6+
import { AuthenticationError } from "../../utils/error";
7+
8+
/**
9+
* These routes are special because they need to be called before we have a JWT.
10+
* They receive headers that cryptographically prove the user owns the inboxes
11+
* and wallet they claim to own.
12+
*
13+
* The headers include:
14+
* - App Check token to verify authentic app build
15+
* - XMTP Installation ID to verify XMTP client
16+
* - XMTP Inbox ID to identify the user's inbox
17+
* - XMTP Signature of the app check token to prove ownership of the inbox
18+
*
19+
* See authentication.readme.md for more details on the authentication flow
20+
* and how these headers are used to establish trust before issuing a JWT.
21+
*/
22+
const AuthenticationRoutes = ["/api/v1/users", "/api/v1/authenticate"] as const;
23+
24+
export const headersInterceptor = async (config: AxiosRequestConfig) => {
25+
const url = config.url;
26+
27+
if (!url) {
28+
throw new AuthenticationError("No URL provided in request config");
29+
}
30+
31+
const needsAuthenticationHeaders = AuthenticationRoutes.some((route) =>
32+
url.includes(route)
33+
);
34+
35+
const headers = needsAuthenticationHeaders
36+
? await getConvosAuthenticationHeaders()
37+
: await getConvosAuthenticatedHeaders();
38+
39+
config.headers = {
40+
...config.headers,
41+
...headers,
42+
};
43+
44+
return config;
45+
};

0 commit comments

Comments
 (0)