Skip to content

Commit 80bc59f

Browse files
committed
Merge branch 'auth' into mobile-app
2 parents 1fcaadb + 578efd2 commit 80bc59f

File tree

12 files changed

+773
-17
lines changed

12 files changed

+773
-17
lines changed

.husky/pre-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/bin/bash
22

3-
pnpm lint-staged
3+
# pnpm lint-staged

apps/mobile/App.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,39 @@
1-
import "./global.css";
1+
import './global.css';
2+
import { StatusBar } from 'expo-status-bar';
3+
import { View, ActivityIndicator } from 'react-native';
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5+
import { useEffect } from 'react';
6+
import { useAuthStore } from './src/stores/authStore';
7+
import { AuthScreen } from './src/screens/AuthScreen';
8+
import { HomeScreen } from './src/screens/HomeScreen';
29

3-
import { StatusBar } from "expo-status-bar";
4-
import { Text, View } from "react-native";
10+
const queryClient = new QueryClient();
11+
12+
function AppContent() {
13+
const { isAuthenticated, isLoading, initializeAuth } = useAuthStore();
14+
15+
useEffect(() => {
16+
initializeAuth();
17+
}, [initializeAuth]);
18+
19+
if (isLoading) {
20+
return (
21+
<View className="flex-1 bg-dark-bg items-center justify-center">
22+
<ActivityIndicator size="large" color="#f97316" />
23+
</View>
24+
);
25+
}
26+
27+
return isAuthenticated ? <HomeScreen /> : <AuthScreen />;
28+
}
529

630
export default function App() {
731
return (
8-
<View className="flex-1 items-center justify-center bg-white">
9-
<Text className="text-xl font-bold text-blue-500">
10-
Welcome to Nativewind!
11-
</Text>
12-
<StatusBar style="auto" />
13-
</View>
32+
<QueryClientProvider client={queryClient}>
33+
<View className="flex-1 bg-dark-bg">
34+
<AppContent />
35+
<StatusBar style="light" />
36+
</View>
37+
</QueryClientProvider>
1438
);
1539
}

apps/mobile/app.json

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
11
{
22
"expo": {
3-
"name": "mobile",
4-
"slug": "mobile",
3+
"name": "PostHog Mobile",
4+
"slug": "posthog-mobile",
55
"version": "1.0.0",
66
"orientation": "portrait",
77
"icon": "./assets/icon.png",
8-
"userInterfaceStyle": "light",
8+
"userInterfaceStyle": "dark",
99
"newArchEnabled": true,
1010
"bundler": "metro",
11+
"scheme": "posthog-mobile",
1112
"splash": {
1213
"image": "./assets/splash-icon.png",
1314
"resizeMode": "contain",
14-
"backgroundColor": "#ffffff"
15+
"backgroundColor": "#0f0f0f"
1516
},
1617
"ios": {
17-
"supportsTablet": true
18+
"supportsTablet": true,
19+
"bundleIdentifier": "com.posthog.mobile"
1820
},
1921
"android": {
2022
"adaptiveIcon": {
2123
"foregroundImage": "./assets/adaptive-icon.png",
22-
"backgroundColor": "#ffffff"
24+
"backgroundColor": "#0f0f0f"
2325
},
2426
"edgeToEdgeEnabled": true,
25-
"predictiveBackGestureEnabled": false
27+
"predictiveBackGestureEnabled": false,
28+
"package": "com.posthog.mobile"
2629
},
2730
"web": {
2831
"favicon": "./assets/favicon.png"
2932
}
33+
3034
}
3135
}

apps/mobile/src/constants/oauth.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CloudRegion } from '../types/oauth';
2+
3+
export const POSTHOG_US_CLIENT_ID = 'HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W';
4+
export const POSTHOG_EU_CLIENT_ID = 'AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9';
5+
export const POSTHOG_DEV_CLIENT_ID = 'DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ';
6+
7+
export const OAUTH_SCOPES = [
8+
'user:read',
9+
'project:read',
10+
'task:write',
11+
'integration:read',
12+
];
13+
14+
// Token refresh settings
15+
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry
16+
17+
export function getCloudUrlFromRegion(region: CloudRegion): string {
18+
switch (region) {
19+
case 'us':
20+
return 'https://us.posthog.com';
21+
case 'eu':
22+
return 'https://eu.posthog.com';
23+
case 'dev':
24+
return 'http://localhost:8010';
25+
}
26+
}
27+
28+
export function getOauthClientIdFromRegion(region: CloudRegion): string {
29+
switch (region) {
30+
case 'us':
31+
return POSTHOG_US_CLIENT_ID;
32+
case 'eu':
33+
return POSTHOG_EU_CLIENT_ID;
34+
case 'dev':
35+
return POSTHOG_DEV_CLIENT_ID;
36+
}
37+
}

apps/mobile/src/hooks/useAuth.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useAuthStore } from '../stores/authStore';
2+
3+
/**
4+
* A convenience hook for accessing common auth state and methods.
5+
*/
6+
export function useAuth() {
7+
const {
8+
isAuthenticated,
9+
isLoading,
10+
oauthAccessToken,
11+
cloudRegion,
12+
projectId,
13+
loginWithOAuth,
14+
logout,
15+
refreshAccessToken,
16+
initializeAuth,
17+
} = useAuthStore();
18+
19+
return {
20+
// State
21+
isAuthenticated,
22+
isLoading,
23+
accessToken: oauthAccessToken,
24+
cloudRegion,
25+
projectId,
26+
27+
// Methods
28+
login: loginWithOAuth,
29+
logout,
30+
refresh: refreshAccessToken,
31+
initialize: initializeAuth,
32+
};
33+
}

apps/mobile/src/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Types
2+
export type { CloudRegion, OAuthTokenResponse, OAuthConfig, StoredTokens } from './types/oauth';
3+
4+
// Constants
5+
export {
6+
POSTHOG_US_CLIENT_ID,
7+
POSTHOG_EU_CLIENT_ID,
8+
POSTHOG_DEV_CLIENT_ID,
9+
OAUTH_SCOPES,
10+
TOKEN_REFRESH_BUFFER_MS,
11+
getCloudUrlFromRegion,
12+
getOauthClientIdFromRegion,
13+
} from './constants/oauth';
14+
15+
// OAuth utilities
16+
export {
17+
performOAuthFlow,
18+
refreshAccessToken,
19+
getRedirectUri,
20+
} from './lib/oauth';
21+
22+
// Secure storage
23+
export { saveTokens, getTokens, deleteTokens } from './lib/secureStorage';
24+
25+
// Store
26+
export { useAuthStore } from './stores/authStore';
27+
28+
// Screens
29+
export { AuthScreen } from './screens/AuthScreen';
30+
export { HomeScreen } from './screens/HomeScreen';

apps/mobile/src/lib/oauth.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import * as AuthSession from 'expo-auth-session';
2+
import * as WebBrowser from 'expo-web-browser';
3+
import * as Crypto from 'expo-crypto';
4+
import {
5+
getCloudUrlFromRegion,
6+
getOauthClientIdFromRegion,
7+
OAUTH_SCOPES,
8+
} from '../constants/oauth';
9+
import type { CloudRegion, OAuthTokenResponse, OAuthConfig } from '../types/oauth';
10+
11+
// Required for web browser auth session to work properly
12+
WebBrowser.maybeCompleteAuthSession();
13+
14+
// Generate PKCE code verifier and challenge
15+
async function generateCodeVerifier(): Promise<string> {
16+
const randomBytes = await Crypto.getRandomBytesAsync(32);
17+
return btoa(String.fromCharCode(...randomBytes))
18+
.replace(/\+/g, '-')
19+
.replace(/\//g, '_')
20+
.replace(/=/g, '');
21+
}
22+
23+
async function generateCodeChallenge(verifier: string): Promise<string> {
24+
const encoder = new TextEncoder();
25+
const data = encoder.encode(verifier);
26+
const digest = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, data);
27+
28+
return btoa(String.fromCharCode(...new Uint8Array(digest)))
29+
.replace(/\+/g, '-')
30+
.replace(/\//g, '_')
31+
.replace(/=/g, '');
32+
}
33+
34+
export function getRedirectUri(): string {
35+
return AuthSession.makeRedirectUri({
36+
scheme: 'posthog-mobile',
37+
path: 'callback',
38+
});
39+
}
40+
41+
export function getAuthorizationEndpoint(region: CloudRegion): string {
42+
return `${getCloudUrlFromRegion(region)}/oauth/authorize`;
43+
}
44+
45+
export function getTokenEndpoint(region: CloudRegion): string {
46+
return `${getCloudUrlFromRegion(region)}/oauth/token`;
47+
}
48+
49+
export async function exchangeCodeForToken(
50+
code: string,
51+
codeVerifier: string,
52+
config: OAuthConfig
53+
): Promise<OAuthTokenResponse> {
54+
const cloudUrl = getCloudUrlFromRegion(config.cloudRegion);
55+
const redirectUri = getRedirectUri();
56+
57+
const response = await fetch(`${cloudUrl}/oauth/token`, {
58+
method: 'POST',
59+
headers: {
60+
'Content-Type': 'application/json',
61+
},
62+
body: JSON.stringify({
63+
grant_type: 'authorization_code',
64+
code,
65+
redirect_uri: redirectUri,
66+
client_id: getOauthClientIdFromRegion(config.cloudRegion),
67+
code_verifier: codeVerifier,
68+
}),
69+
});
70+
71+
if (!response.ok) {
72+
const errorText = await response.text();
73+
throw new Error(`Token exchange failed: ${response.statusText} - ${errorText}`);
74+
}
75+
76+
return response.json();
77+
}
78+
79+
export async function refreshAccessToken(
80+
refreshToken: string,
81+
region: CloudRegion
82+
): Promise<OAuthTokenResponse> {
83+
const cloudUrl = getCloudUrlFromRegion(region);
84+
85+
const response = await fetch(`${cloudUrl}/oauth/token`, {
86+
method: 'POST',
87+
headers: {
88+
'Content-Type': 'application/json',
89+
},
90+
body: JSON.stringify({
91+
grant_type: 'refresh_token',
92+
refresh_token: refreshToken,
93+
client_id: getOauthClientIdFromRegion(region),
94+
}),
95+
});
96+
97+
if (!response.ok) {
98+
throw new Error(`Token refresh failed: ${response.statusText}`);
99+
}
100+
101+
return response.json();
102+
}
103+
104+
export interface OAuthFlowResult {
105+
success: boolean;
106+
data?: OAuthTokenResponse;
107+
error?: string;
108+
}
109+
110+
export async function performOAuthFlow(config: OAuthConfig): Promise<OAuthFlowResult> {
111+
try {
112+
const codeVerifier = await generateCodeVerifier();
113+
const codeChallenge = await generateCodeChallenge(codeVerifier);
114+
const redirectUri = getRedirectUri();
115+
const clientId = getOauthClientIdFromRegion(config.cloudRegion);
116+
117+
const discovery: AuthSession.DiscoveryDocument = {
118+
authorizationEndpoint: getAuthorizationEndpoint(config.cloudRegion),
119+
tokenEndpoint: getTokenEndpoint(config.cloudRegion),
120+
};
121+
122+
const authRequest = new AuthSession.AuthRequest({
123+
clientId,
124+
scopes: config.scopes,
125+
redirectUri,
126+
codeChallenge,
127+
codeChallengeMethod: AuthSession.CodeChallengeMethod.S256,
128+
extraParams: {
129+
required_access_level: 'project',
130+
},
131+
});
132+
133+
const authResult = await authRequest.promptAsync(discovery);
134+
135+
if (authResult.type === 'cancel' || authResult.type === 'dismiss') {
136+
return {
137+
success: false,
138+
error: 'Authorization cancelled',
139+
};
140+
}
141+
142+
if (authResult.type === 'error') {
143+
return {
144+
success: false,
145+
error: authResult.error?.message || 'Authorization failed',
146+
};
147+
}
148+
149+
if (authResult.type !== 'success' || !authResult.params.code) {
150+
return {
151+
success: false,
152+
error: 'No authorization code received',
153+
};
154+
}
155+
156+
const tokenResponse = await exchangeCodeForToken(
157+
authResult.params.code,
158+
codeVerifier,
159+
config
160+
);
161+
162+
return {
163+
success: true,
164+
data: tokenResponse,
165+
};
166+
} catch (error) {
167+
return {
168+
success: false,
169+
error: error instanceof Error ? error.message : 'Unknown error',
170+
};
171+
}
172+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as SecureStore from 'expo-secure-store';
2+
import type { StoredTokens } from '../types/oauth';
3+
4+
const TOKENS_KEY = 'posthog_oauth_tokens';
5+
6+
export async function saveTokens(tokens: StoredTokens): Promise<void> {
7+
await SecureStore.setItemAsync(TOKENS_KEY, JSON.stringify(tokens));
8+
}
9+
10+
export async function getTokens(): Promise<StoredTokens | null> {
11+
const value = await SecureStore.getItemAsync(TOKENS_KEY);
12+
if (!value) return null;
13+
14+
try {
15+
return JSON.parse(value) as StoredTokens;
16+
} catch {
17+
return null;
18+
}
19+
}
20+
21+
export async function deleteTokens(): Promise<void> {
22+
await SecureStore.deleteItemAsync(TOKENS_KEY);
23+
}

0 commit comments

Comments
 (0)