Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 1 | import { encodeOAuthClient } from "@g1t/contracts/oauth"; |
| 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. | 2 | import Constants, { ExecutionEnvironment } from "expo-constants"; |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 3 | import * as AuthSession from "expo-auth-session"; |
| 4 | import * as WebBrowser from "expo-web-browser"; | |
| 5 | import { Platform } from "react-native"; | |
| 6 | ||
| 7 | import type { Credentials } from "./credentials"; | |
| 8 | import { OAUTH_CALLBACK_PATH } from "./links"; | |
| 9 | import type { Server } from "./server"; | |
| 10 | ||
| 11 | /** | |
| 12 | * Signing in with g1t's OAuth 2.1, as any public client does | |
| 13 | * (docs.g1t.sh/guides/authentication/#signing-in-with-oauth): the | |
| 14 | * authorization code flow with PKCE (S256), no client secret, and a client | |
| 15 | * id that encodes the app's name and where it is sent back, so nothing is | |
| 16 | * registered first. | |
| 17 | * | |
| 18 | * The person approves on the site, in the system's browser, which keeps | |
| 19 | * their g1t session (and passkeys, password managers, two-factor) out of | |
| 20 | * the app. The site sends them back to `g1t://oauth/callback`. | |
| 21 | * | |
| 22 | * The app appears in Settings → Connected applications as "g1t for | |
| 23 | * Android" or "g1t for iOS", where its access can be narrowed or ended. | |
| 24 | */ | |
| 25 | ||
| 26 | // Lets the browser hand its result back when the app was in the background. | |
| 27 | WebBrowser.maybeCompleteAuthSession(); | |
| 28 | ||
| 29 | /** | |
| 30 | * What the app asks for: what Chat, Code and Customize show and do. The | |
| 31 | * person can untick any of these on the approval page. Chat has no scope: | |
| 32 | * see the README's "Chat and OAuth tokens". | |
| 33 | */ | |
| 34 | export const SCOPES = [ | |
| 35 | "account:read", | |
| 36 | "workspace:read", | |
| 37 | "repo:read", | |
| 38 | "code:read", | |
| 39 | "issues:read", | |
| 40 | "issues:write", | |
| 41 | "pull_requests:read", | |
| 42 | "pull_requests:write", | |
| 43 | "checks:read", | |
| 44 | "workflows:read", | |
| 45 | "notifications:read", | |
| 46 | "notifications:write", | |
| 47 | "agents:run", | |
| 48 | "memory:read", | |
| 49 | "artifacts:read", | |
| 50 | ]; | |
| 51 | ||
| 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. | 52 | /** Whether the app is running inside Expo Go, while developing. */ |
| 53 | export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient; | |
| 54 | ||
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 55 | /** The name shown on the approval page and in Connected applications. */ |
| 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. | 56 | const CLIENT_NAME = IN_EXPO_GO ? "g1t in Expo Go" : Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android"; |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 57 | |
| 58 | /** | |
| 59 | * Where the site sends the person back: `g1t://oauth/callback` in a build | |
| 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. | 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. | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 65 | */ |
| 66 | export function redirectUri(): string { | |
| 67 | return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) }); | |
| 68 | } | |
| 69 | ||
| 70 | export function clientId(redirect: string): string { | |
| 71 | const id = encodeOAuthClient({ name: CLIENT_NAME, redirectUris: [redirect] }); | |
| 72 | if (!id) throw new Error(`g1t does not accept ${redirect} as a place to send you back.`); | |
| 73 | return id; | |
| 74 | } | |
| 75 | ||
| 76 | type Metadata = { | |
| 77 | issuer: string; | |
| 78 | authorization_endpoint: string; | |
| 79 | token_endpoint: string; | |
| 80 | revocation_endpoint?: string; | |
| 81 | }; | |
| 82 | ||
| 83 | /** The server's OAuth metadata (RFC 8414), from its API. */ | |
| 84 | async function metadata(server: Server): Promise<Metadata> { | |
| 85 | const response = await fetch(`${server.api}/.well-known/oauth-authorization-server`, { | |
| 86 | headers: { accept: "application/json" }, | |
| 87 | }); | |
| 88 | if (!response.ok) throw new Error(`${server.api} did not say how to sign in (${response.status}).`); | |
| 89 | return (await response.json()) as Metadata; | |
| 90 | } | |
| 91 | ||
| 92 | function discovery(meta: Metadata): AuthSession.DiscoveryDocument { | |
| 93 | return { | |
| 94 | authorizationEndpoint: meta.authorization_endpoint, | |
| 95 | tokenEndpoint: meta.token_endpoint, | |
| 96 | revocationEndpoint: meta.revocation_endpoint, | |
| 97 | }; | |
| 98 | } | |
| 99 | ||
| 100 | export class SignInCancelled extends Error { | |
| 101 | constructor() { | |
| 102 | super("Sign-in was cancelled."); | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Opens the site's approval page and, once the person approves, trades the | |
| 108 | * code for tokens. Throws {@link SignInCancelled} when they close the page | |
| 109 | * or deny. | |
| 110 | */ | |
| 111 | export async function signInWithOAuth(server: Server): Promise<Credentials> { | |
| 112 | const meta = await metadata(server); | |
| 113 | const redirect = redirectUri(); | |
| 114 | const id = clientId(redirect); | |
| 115 | const request = new AuthSession.AuthRequest({ | |
| 116 | clientId: id, | |
| 117 | redirectUri: redirect, | |
| 118 | scopes: SCOPES, | |
| 119 | usePKCE: true, | |
| 120 | codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, | |
| 121 | }); | |
| 122 | const result = await request.promptAsync(discovery(meta)); | |
| 123 | if (result.type === "cancel" || result.type === "dismiss") throw new SignInCancelled(); | |
| 124 | if (result.type !== "success") { | |
| 125 | const denied = result.type === "error" && result.params.error === "access_denied"; | |
| 126 | if (denied) throw new SignInCancelled(); | |
| 127 | throw new Error(result.type === "error" ? (result.params.error_description ?? result.params.error ?? "Sign-in failed.") : "Sign-in failed."); | |
| 128 | } | |
| 129 | // RFC 9207: the code must come from the server the app asked. | |
| 130 | if (result.params.iss && result.params.iss.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) { | |
| 131 | throw new Error("The sign-in came back from a different server than the one asked. Try again."); | |
| 132 | } | |
| 133 | const tokens = await AuthSession.exchangeCodeAsync( | |
| 134 | { | |
| 135 | clientId: id, | |
| 136 | code: result.params.code, | |
| 137 | redirectUri: redirect, | |
| 138 | extraParams: { code_verifier: request.codeVerifier ?? "" }, | |
| 139 | }, | |
| 140 | discovery(meta), | |
| 141 | ); | |
| 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. | 142 | return fromTokenResponse(tokens, id); |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 143 | } |
| 144 | ||
| 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. | 145 | function fromTokenResponse(tokens: AuthSession.TokenResponse, clientId: string): Credentials { |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 146 | if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token."); |
| 147 | return { | |
| 148 | kind: "oauth", | |
| 149 | accessToken: tokens.accessToken, | |
| 150 | refreshToken: tokens.refreshToken, | |
| 151 | expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000, | |
| 152 | scope: tokens.scope ?? "", | |
| 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. | 153 | clientId, |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 154 | }; |
| 155 | } | |
| 156 | ||
| 157 | /** Raised when the refresh token no longer works: the person must sign in again. */ | |
| 158 | export class SignedOut extends Error { | |
| 159 | constructor(message = "You were signed out. Sign in again.") { | |
| 160 | super(message); | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * The next pair of tokens. A refresh token works once: the old access | |
| 166 | * token stops working, so callers share one refresh at a time | |
| 167 | * (lib/client.ts). | |
| 168 | */ | |
| 169 | export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> { | |
| 170 | const meta = await metadata(server); | |
| 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. | 171 | const id = credentials.clientId ?? clientId(redirectUri()); |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 172 | try { |
| 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. | 173 | const tokens = await AuthSession.refreshAsync({ clientId: id, refreshToken: credentials.refreshToken }, discovery(meta)); |
| 174 | return fromTokenResponse(tokens, id); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 175 | } catch (error) { |
| 176 | // invalid_grant: used, expired or revoked in Connected applications. | |
| 177 | if (error instanceof AuthSession.TokenError) throw new SignedOut(); | |
| 178 | throw error; | |
| 179 | } | |
| 180 | } |