├── .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 |
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 |
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 |