Skip to content

Commit 578efd2

Browse files
committed
auth
1 parent c68999e commit 578efd2

File tree

11 files changed

+902
-13
lines changed

11 files changed

+902
-13
lines changed

apps/mobile/App.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,50 @@
11
import { StatusBar } from 'expo-status-bar';
2-
import { StyleSheet, Text, View } from 'react-native';
2+
import { StyleSheet, View, ActivityIndicator } from 'react-native';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { useEffect } from 'react';
5+
import { useAuthStore } from './src/stores/authStore';
6+
import { AuthScreen } from './src/screens/AuthScreen';
7+
import { HomeScreen } from './src/screens/HomeScreen';
8+
9+
const queryClient = new QueryClient();
10+
11+
function AppContent() {
12+
const { isAuthenticated, isLoading, initializeAuth } = useAuthStore();
13+
14+
useEffect(() => {
15+
initializeAuth();
16+
}, [initializeAuth]);
17+
18+
if (isLoading) {
19+
return (
20+
<View style={styles.loadingContainer}>
21+
<ActivityIndicator size="large" color="#f97316" />
22+
</View>
23+
);
24+
}
25+
26+
return isAuthenticated ? <HomeScreen /> : <AuthScreen />;
27+
}
328

429
export default function App() {
530
return (
6-
<View style={styles.container}>
7-
<Text>Open up App.tsx to start working on your app!</Text>
8-
<StatusBar style="auto" />
9-
</View>
31+
<QueryClientProvider client={queryClient}>
32+
<View style={styles.container}>
33+
<AppContent />
34+
<StatusBar style="light" />
35+
</View>
36+
</QueryClientProvider>
1037
);
1138
}
1239

1340
const styles = StyleSheet.create({
1441
container: {
1542
flex: 1,
16-
backgroundColor: '#fff',
43+
backgroundColor: '#0f0f0f',
44+
},
45+
loadingContainer: {
46+
flex: 1,
47+
backgroundColor: '#0f0f0f',
1748
alignItems: 'center',
1849
justifyContent: 'center',
1950
},

apps/mobile/app.json

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
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,
10+
"scheme": "posthog-mobile",
1011
"splash": {
1112
"image": "./assets/splash-icon.png",
1213
"resizeMode": "contain",
13-
"backgroundColor": "#ffffff"
14+
"backgroundColor": "#0f0f0f"
1415
},
1516
"ios": {
16-
"supportsTablet": true
17+
"supportsTablet": true,
18+
"bundleIdentifier": "com.posthog.mobile"
1719
},
1820
"android": {
1921
"adaptiveIcon": {
2022
"foregroundImage": "./assets/adaptive-icon.png",
21-
"backgroundColor": "#ffffff"
23+
"backgroundColor": "#0f0f0f"
2224
},
2325
"edgeToEdgeEnabled": true,
24-
"predictiveBackGestureEnabled": false
26+
"predictiveBackGestureEnabled": false,
27+
"package": "com.posthog.mobile"
2528
},
2629
"web": {
2730
"favicon": "./assets/favicon.png"
2831
}
32+
2933
}
3034
}

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)