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