├── .env ├── .gitignore ├── README.md ├── app.json ├── app ├── +not-found.tsx ├── _layout.tsx ├── auth │ ├── pending.tsx │ ├── proxy+api.tsx │ └── proxy-expo-go+api.tsx ├── index.tsx └── modal.tsx ├── assets ├── fonts │ └── SpaceMono-Regular.ttf └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png ├── babel.config.js ├── bun.lockb ├── components ├── EditScreenInfo.tsx ├── ExternalLink.tsx ├── StyledText.tsx ├── Themed.tsx ├── __tests__ │ └── StyledText-test.js ├── useClientOnlyValue.ts ├── useClientOnlyValue.web.ts ├── useColorScheme.ts └── useColorScheme.web.ts ├── constants └── Colors.ts ├── package.json └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | # https://dev.twitch.tv/console/apps/create 2 | EXPO_PUBLIC_TWITCH_CLIENT_ID= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 38 | # The following patterns were generated by expo-cli 39 | 40 | expo-env.d.ts 41 | # @end expo-cli -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expo Router Auth Proxy example 2 | 3 | Here's my Twitch setup (May 2nd, 2024) 4 | 5 | Screenshot 2024-05-02 at 4 25 37 PM 6 | 7 | Notes: 8 | - It looks like you can use implicit authentication now (this wasn't the case when I wrote the original Twitch auth guide). 9 | - Even though they support native apps, they no longer support non-http redirect URIs. This prompted the example which uses a server redirect to our native app. 10 | 11 | Screenshot 2024-05-02 at 3 18 54 PM 12 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "may2-redirect", 4 | "slug": "may2-redirect", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "twitch-auth-demo", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "assetBundlePatterns": ["**/*"], 16 | "ios": { 17 | "supportsTablet": true 18 | }, 19 | "android": { 20 | "adaptiveIcon": { 21 | "foregroundImage": "./assets/images/adaptive-icon.png", 22 | "backgroundColor": "#ffffff" 23 | } 24 | }, 25 | "web": { 26 | "bundler": "metro", 27 | "output": "server", 28 | "favicon": "./assets/images/favicon.png" 29 | }, 30 | "plugins": ["expo-router"], 31 | "experiments": { 32 | "typedRoutes": true 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from 'expo-router'; 2 | import { StyleSheet } from 'react-native'; 3 | 4 | import { Text, View } from '@/components/Themed'; 5 | 6 | export default function NotFoundScreen() { 7 | return ( 8 | <> 9 | 10 | 11 | This screen doesn't exist. 12 | 13 | 14 | Go to home screen! 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | container: { 23 | flex: 1, 24 | alignItems: 'center', 25 | justifyContent: 'center', 26 | padding: 20, 27 | }, 28 | title: { 29 | fontSize: 20, 30 | fontWeight: 'bold', 31 | }, 32 | link: { 33 | marginTop: 15, 34 | paddingVertical: 15, 35 | }, 36 | linkText: { 37 | fontSize: 14, 38 | color: '#2e78b7', 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | export { 4 | // Catch any errors thrown by the Layout component. 5 | ErrorBoundary, 6 | } from "expo-router"; 7 | 8 | export const unstable_settings = { 9 | // Ensure that reloading on `/modal` keeps a back button present. 10 | initialRouteName: "index", 11 | }; 12 | 13 | export default function RootLayout() { 14 | return ( 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/auth/pending.tsx: -------------------------------------------------------------------------------- 1 | import * as WebBrowser from "expo-web-browser"; 2 | import { Text, View } from "react-native"; 3 | /* @info Web only: This method should be invoked on the page that the auth popup gets redirected to on web, it'll ensure that authentication is completed properly. On native this does nothing. */ 4 | WebBrowser.maybeCompleteAuthSession(); 5 | /* @end */ 6 | 7 | export default function App() { 8 | return ( 9 | 10 | Finishing Authentication on web 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/auth/proxy+api.tsx: -------------------------------------------------------------------------------- 1 | // Redirect to native app with the code 2 | export function GET(req: Request): Response { 3 | // Ensure this matches the scheme in your native app. Remember, scheme's aren't secure as other apps could 4 | // register the same scheme and intercept the access token. 5 | // A more thorough implementation would involve a state parameter to prevent CSRF attacks. 6 | // Even better would be an Expo webpage which the native auth session points to, this performs auth and code exchange, then redirects back with the access token. 7 | const redirectUri = 8 | `twitch-auth-demo://?` + new URL(req.url, "http://a").searchParams; 9 | 10 | console.log("Redirect to app:", redirectUri); 11 | 12 | return Response.redirect(redirectUri, 302); 13 | } 14 | -------------------------------------------------------------------------------- /app/auth/proxy-expo-go+api.tsx: -------------------------------------------------------------------------------- 1 | // Redirect to native app with the code 2 | export function GET(req: Request): Response { 3 | // This is fragile and won't work in many cases. It's just an example. Physical devices, and android emulators will need the full IP address instead of localhost. 4 | // This also assumes the dev server is running on port 8081. 5 | const redirectUri = 6 | `exp://localhost:8081/--/?` + new URL(req.url, "http://a").searchParams; 7 | 8 | console.log("Redirect to app:", redirectUri); 9 | 10 | return Response.redirect(redirectUri, 302); 11 | } 12 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { useAuthRequest, TokenResponse } from "expo-auth-session"; 4 | import { Button, Platform, Text, View } from "react-native"; 5 | import Constants, { ExecutionEnvironment } from "expo-constants"; 6 | 7 | // Endpoint 8 | const discovery = { 9 | authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", 10 | tokenEndpoint: "https://id.twitch.tv/oauth2/token", 11 | revocationEndpoint: "https://id.twitch.tv/oauth2/revoke", 12 | }; 13 | 14 | const isExpoGo = 15 | Constants.executionEnvironment === ExecutionEnvironment.StoreClient; 16 | 17 | const serverOrigin = 18 | process.env.NODE_ENV === "development" 19 | ? "http://localhost:8081/" 20 | : // TODO: Set this as your production dev server location. You can also configure this using an environment variable for preview deployments. 21 | "https://.../"; 22 | 23 | const proxyUrl = new URL( 24 | // This changes because we have a naive proxy that hardcodes the redirect URL. 25 | Platform.select({ 26 | native: isExpoGo ? "/auth/proxy-expo-go" : "/auth/proxy", 27 | // This can basically be any web URL. 28 | default: "/auth/pending", 29 | }), 30 | serverOrigin 31 | ).toString(); 32 | 33 | export default function App() { 34 | const [accessToken, setAccessToken] = React.useState(null); 35 | 36 | if (accessToken) { 37 | return ( 38 | 39 | Access Token: 40 | {accessToken} 41 | 42 | ); 43 | } 44 | 45 | return ( 46 | 47 | setAccessToken(auth.accessToken)} /> 48 | 49 | ); 50 | } 51 | function TwitchAuthButton({ 52 | onAuth, 53 | }: { 54 | onAuth: (auth: TokenResponse) => void; 55 | }) { 56 | const [request, response, promptAsync] = useAuthRequest( 57 | { 58 | clientId: process.env.EXPO_PUBLIC_TWITCH_CLIENT_ID!, 59 | scopes: ["user:read:email", "analytics:read:games"], 60 | 61 | // Use implicit flow to avoid code exchange. 62 | responseType: "token", 63 | redirectUri: proxyUrl, 64 | // Enable PKCE (Proof Key for Code Exchange) to prevent another app from intercepting the redirect request. 65 | usePKCE: true, 66 | }, 67 | discovery 68 | ); 69 | console.log("Redirect", request); 70 | 71 | React.useEffect(() => { 72 | if (request && response?.type === "success" && response.authentication) { 73 | console.log("Access Token:", response.authentication?.accessToken); 74 | onAuth(response.authentication); 75 | } 76 | }, [response, onAuth]); 77 | 78 | return ( 79 |