The phone app signs in from Expo Go and stays signed in there. Go sends sign-in back to its own exp:// address, which follows the computer's network address, and a client id encodes its redirect, so a refresh after the address changed named another client and was refused. Credentials now keep the client id they were issued to and refresh with it; Go's client is named "g1t in Expo Go" on the approval page and in Connected applications. The README leads with running in Expo Go, and says what a development build adds and how to point the phone at a g1t on your computer.
- Claude-Session:
- https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
3 files+62−180/3 viewed
| 79 | 79 | ||
| 80 | 80 | ## Run it | |
| 81 | 81 | ||
| 82 | − | You need Node 22 (the repository's), and for Android, Android Studio with | |
| 83 | − | an emulator or a phone with USB debugging. | |
| 82 | + | You need Node 22 (the repository's). | |
| 84 | 83 | ||
| 84 | + | ### In Expo Go (quickest) | |
| 85 | + | ||
| 86 | + | Everything the app uses is in Expo Go for SDK 57, so no native build is | |
| 87 | + | needed: | |
| 88 | + | ||
| 85 | 89 | ```sh | |
| 86 | 90 | npm install # at the repository's root: one workspace | |
| 87 | 91 | cd apps/mobile | |
| 88 | − | npx expo run:android # builds a development app and starts Metro | |
| 92 | + | npx expo start # then scan the QR code with Expo Go | |
| 89 | 93 | ``` | |
| 90 | 94 | ||
| 91 | − | `expo run:android` makes the native project (`android/`, ignored by git) | |
| 92 | − | and installs `g1t (dev)`. After the first build, `npx expo start` and | |
| 93 | − | the installed app are enough until a native module changes. | |
| 95 | + | Install [Expo Go](https://expo.dev/go) on the phone, or press `a` for an | |
| 96 | + | Android emulator. The phone and the computer must be on the same network | |
| 97 | + | (or use `npx expo start --tunnel`). | |
| 94 | 98 | ||
| 95 | − | Expo Go works for screens that need nothing native beyond what Go has, | |
| 96 | − | and sign-in comes back to Go's own `exp://` address (allowed as a | |
| 97 | − | redirect). `g1t://` links need a development build. | |
| 99 | + | Signing in works in Go: the site sends you back to Go's own address, | |
| 100 | + | `exp://<your computer>:8081/--/oauth/callback`, which g1t accepts as an | |
| 101 | + | application's own scheme. The approval page and Connected applications | |
| 102 | + | call it **g1t in Expo Go**. Its client id encodes that address, so a new | |
| 103 | + | network makes a new client: the app keeps the id it signed in with for | |
| 104 | + | refreshing, and signing in again after a move just adds a new entry to | |
| 105 | + | Connected applications. What Go can't do: open from `g1t://` links (they | |
| 106 | + | go to an installed build) and show the app's own name and icon. | |
| 107 | + | ||
| 108 | + | To develop against a g1t running on your computer, choose **Server** on | |
| 109 | + | the sign-in screen and give its address on your network, such as | |
| 110 | + | `http://192.168.1.20:5173` and the API's `http://192.168.1.20:8789` | |
| 111 | + | (`localhost` on a phone is the phone). Run that g1t with its `SITE_URL` | |
| 112 | + | and `API_URL` set to those addresses too: the API's OAuth metadata | |
| 113 | + | names the sign-in page by `SITE_URL`, and the phone opens what it names. | |
| 114 | + | ||
| 115 | + | ### In a development build | |
| 116 | + | ||
| 117 | + | For `g1t://` links, the app's own icon and name, and anything native Go | |
| 118 | + | lacks later (push notifications): | |
| 119 | + | ||
| 120 | + | ```sh | |
| 121 | + | npx expo run:android # builds g1t (dev) and starts Metro | |
| 122 | + | ``` | |
| 123 | + | ||
| 124 | + | `expo run:android` makes the native project (`android/`, ignored by git) | |
| 125 | + | and installs `g1t (dev)`, which signs in through `g1t://oauth/callback`. | |
| 126 | + | After the first build, `npx expo start` and the installed app are enough | |
| 127 | + | until a native module changes. `eas build --profile development` makes | |
| 128 | + | the same build in the cloud. | |
| 98 | 129 | ||
| 99 | 130 | Checks, which CI runs from the root (`npm run typecheck`, `npm test`): | |
| 100 | 131 |
| 23 | 23 | expiresAt: number; | |
| 24 | 24 | /** The scopes the person granted, or `*` for full access. */ | |
| 25 | 25 | scope: string; | |
| 26 | + | /** | |
| 27 | + | * The client id it was issued to: a refresh must name the same one. | |
| 28 | + | * Kept, because in Expo Go the id follows the computer's address, | |
| 29 | + | * which changes between sessions (lib/oauth.ts). | |
| 30 | + | */ | |
| 31 | + | clientId?: string; | |
| 26 | 32 | } | |
| 27 | 33 | | { kind: "token"; accessToken: string }; | |
| 28 | 34 |
| 1 | 1 | import { encodeOAuthClient } from "@g1t/contracts/oauth"; | |
| 2 | + | import Constants, { ExecutionEnvironment } from "expo-constants"; | |
| 2 | 3 | import * as AuthSession from "expo-auth-session"; | |
| 3 | 4 | import * as WebBrowser from "expo-web-browser"; | |
| 4 | 5 | import { Platform } from "react-native"; | |
| ⋯ | |||
| 48 | 49 | "artifacts:read", | |
| 49 | 50 | ]; | |
| 50 | 51 | ||
| 52 | + | /** Whether the app is running inside Expo Go, while developing. */ | |
| 53 | + | export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient; | |
| 54 | + | ||
| 51 | 55 | /** The name shown on the approval page and in Connected applications. */ | |
| 52 | − | const CLIENT_NAME = Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android"; | |
| 56 | + | const CLIENT_NAME = IN_EXPO_GO ? "g1t in Expo Go" : Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android"; | |
| 53 | 57 | ||
| 54 | 58 | /** | |
| 55 | 59 | * Where the site sends the person back: `g1t://oauth/callback` in a build | |
| 56 | − | * of the app, and Expo Go's own address while developing in it. | |
| 60 | + | * of the app, and Expo Go's own address while developing in it | |
| 61 | + | * (`exp://192.168.1.20:8081/--/oauth/callback`), which g1t accepts as an | |
| 62 | + | * application's own scheme. Since the client id encodes it, Go's id | |
| 63 | + | * changes with the computer's address; credentials keep the id they were | |
| 64 | + | * issued to, for refreshing. | |
| 57 | 65 | */ | |
| 58 | 66 | export function redirectUri(): string { | |
| 59 | 67 | return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) }); | |
| ⋯ | |||
| 131 | 139 | }, | |
| 132 | 140 | discovery(meta), | |
| 133 | 141 | ); | |
| 134 | − | return fromTokenResponse(tokens); | |
| 142 | + | return fromTokenResponse(tokens, id); | |
| 135 | 143 | } | |
| 136 | 144 | ||
| 137 | − | function fromTokenResponse(tokens: AuthSession.TokenResponse): Credentials { | |
| 145 | + | function fromTokenResponse(tokens: AuthSession.TokenResponse, clientId: string): Credentials { | |
| 138 | 146 | if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token."); | |
| 139 | 147 | return { | |
| 140 | 148 | kind: "oauth", | |
| ⋯ | |||
| 142 | 150 | refreshToken: tokens.refreshToken, | |
| 143 | 151 | expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000, | |
| 144 | 152 | scope: tokens.scope ?? "", | |
| 153 | + | clientId, | |
| 145 | 154 | }; | |
| 146 | 155 | } | |
| 147 | 156 | ||
| ⋯ | |||
| 159 | 168 | */ | |
| 160 | 169 | export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> { | |
| 161 | 170 | const meta = await metadata(server); | |
| 171 | + | const id = credentials.clientId ?? clientId(redirectUri()); | |
| 162 | 172 | try { | |
| 163 | − | const tokens = await AuthSession.refreshAsync( | |
| 164 | − | { clientId: clientId(redirectUri()), refreshToken: credentials.refreshToken }, | |
| 165 | − | discovery(meta), | |
| 166 | − | ); | |
| 167 | − | return fromTokenResponse(tokens); | |
| 173 | + | const tokens = await AuthSession.refreshAsync({ clientId: id, refreshToken: credentials.refreshToken }, discovery(meta)); | |
| 174 | + | return fromTokenResponse(tokens, id); | |
| 168 | 175 | } catch (error) { | |
| 169 | 176 | // invalid_grant: used, expired or revoked in Connected applications. | |
| 170 | 177 | if (error instanceof AuthSession.TokenError) throw new SignedOut(); | |