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: Chat in full with phone notifications, plus Code and Customize (#4) | 1 | import { encodeOAuthClient } from "@g1t/contracts/oauth"; |
| 2 | import Constants, { ExecutionEnvironment } from "expo-constants"; | |
| 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:*` opens | |
| 32 | * Chat's routes and live sockets on the site, and `notifications:*` the | |
| 33 | * phone's registration for pushes (`/-/notify`), and nothing else there. | |
| 34 | */ | |
| 35 | export const SCOPES = [ | |
| 36 | "chat:read", | |
| 37 | "chat:write", | |
| 38 | "account:read", | |
| 39 | "workspace:read", | |
| 40 | "repo:read", | |
| 41 | "code:read", | |
| 42 | "issues:read", | |
| 43 | "issues:write", | |
| 44 | "pull_requests:read", | |
| 45 | "pull_requests:write", | |
| 46 | "checks:read", | |
| 47 | "workflows:read", | |
| 48 | "notifications:read", | |
| 49 | "notifications:write", | |
| 50 | "agents:run", | |
| 51 | "memory:read", | |
| 52 | "artifacts:read", | |
| 53 | ]; | |
| 54 | ||
| 55 | /** Whether the app is running inside Expo Go, while developing. */ | |
| 56 | export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient; | |
| 57 | ||
| 58 | /** The name shown on the approval page and in Connected applications. */ | |
| 59 | const CLIENT_NAME = IN_EXPO_GO ? "g1t in Expo Go" : Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android"; | |
| 60 | ||
| 61 | /** | |
| 62 | * Where the site sends the person back: `g1t://oauth/callback` in a build | |
| 63 | * of the app, and Expo Go's own address while developing in it | |
| 64 | * (`exp://192.168.1.20:8081/--/oauth/callback`), which g1t accepts as an | |
| 65 | * application's own scheme. Since the client id encodes it, Go's id | |
| 66 | * changes with the computer's address; credentials keep the id they were | |
| 67 | * issued to, for refreshing. | |
| 68 | */ | |
| 69 | export function redirectUri(): string { | |
| 70 | return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) }); | |
| 71 | } | |
| 72 | ||
| 73 | export function clientId(redirect: string): string { | |
| 74 | const id = encodeOAuthClient({ name: CLIENT_NAME, redirectUris: [redirect] }); | |
| 75 | if (!id) throw new Error(`g1t does not accept ${redirect} as a place to send you back.`); | |
| 76 | return id; | |
| 77 | } | |
| 78 | ||
| 79 | type Metadata = { | |
| 80 | issuer: string; | |
| 81 | authorization_endpoint: string; | |
| 82 | token_endpoint: string; | |
| 83 | revocation_endpoint?: string; | |
| 84 | }; | |
| 85 | ||
| 86 | /** The server's OAuth metadata (RFC 8414), from its API. */ | |
| 87 | async function metadata(server: Server): Promise<Metadata> { | |
| 88 | const response = await fetch(`${server.api}/.well-known/oauth-authorization-server`, { | |
| 89 | headers: { accept: "application/json" }, | |
| 90 | }); | |
| 91 | if (!response.ok) throw new Error(`${server.api} did not say how to sign in (${response.status}).`); | |
| 92 | return (await response.json()) as Metadata; | |
| 93 | } | |
| 94 | ||
| 95 | function discovery(meta: Metadata): AuthSession.DiscoveryDocument { | |
| 96 | return { | |
| 97 | authorizationEndpoint: meta.authorization_endpoint, | |
| 98 | tokenEndpoint: meta.token_endpoint, | |
| 99 | revocationEndpoint: meta.revocation_endpoint, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | export class SignInCancelled extends Error { | |
| 104 | constructor() { | |
| 105 | super("Sign-in was cancelled."); | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Opens the site's approval page and, once the person approves, trades the | |
| 111 | * code for tokens. Throws {@link SignInCancelled} when they close the page | |
| 112 | * or deny. | |
| 113 | */ | |
| 114 | export async function signInWithOAuth(server: Server): Promise<Credentials> { | |
| 115 | const meta = await metadata(server); | |
| 116 | const redirect = redirectUri(); | |
| 117 | const id = clientId(redirect); | |
| 118 | const request = new AuthSession.AuthRequest({ | |
| 119 | clientId: id, | |
| 120 | redirectUri: redirect, | |
| 121 | scopes: SCOPES, | |
| 122 | usePKCE: true, | |
| 123 | codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, | |
| 124 | }); | |
| 125 | const result = await request.promptAsync(discovery(meta)); | |
| 126 | if (result.type === "cancel" || result.type === "dismiss") throw new SignInCancelled(); | |
| 127 | if (result.type !== "success") { | |
| 128 | const denied = result.type === "error" && result.params.error === "access_denied"; | |
| 129 | if (denied) throw new SignInCancelled(); | |
| 130 | throw new Error(result.type === "error" ? (result.params.error_description ?? result.params.error ?? "Sign-in failed.") : "Sign-in failed."); | |
| 131 | } | |
| 132 | // RFC 9207: the code must come from the server the app asked. | |
| 133 | if (result.params.iss && result.params.iss.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) { | |
| 134 | throw new Error("The sign-in came back from a different server than the one asked. Try again."); | |
| 135 | } | |
| 136 | const tokens = await AuthSession.exchangeCodeAsync( | |
| 137 | { | |
| 138 | clientId: id, | |
| 139 | code: result.params.code, | |
| 140 | redirectUri: redirect, | |
| 141 | extraParams: { code_verifier: request.codeVerifier ?? "" }, | |
| 142 | }, | |
| 143 | discovery(meta), | |
| 144 | ); | |
| 145 | return fromTokenResponse(tokens, id); | |
| 146 | } | |
| 147 | ||
| 148 | function fromTokenResponse(tokens: AuthSession.TokenResponse, clientId: string): Credentials { | |
| 149 | if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token."); | |
| 150 | return { | |
| 151 | kind: "oauth", | |
| 152 | accessToken: tokens.accessToken, | |
| 153 | refreshToken: tokens.refreshToken, | |
| 154 | expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000, | |
| 155 | scope: tokens.scope ?? "", | |
| 156 | clientId, | |
| 157 | }; | |
| 158 | } | |
| 159 | ||
| 160 | /** Raised when the refresh token no longer works: the person must sign in again. */ | |
| 161 | export class SignedOut extends Error { | |
| 162 | constructor(message = "You were signed out. Sign in again.") { | |
| 163 | super(message); | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * The next pair of tokens. A refresh token works once: the old access | |
| 169 | * token stops working, so callers share one refresh at a time | |
| 170 | * (lib/client.ts). | |
| 171 | */ | |
| 172 | export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> { | |
| 173 | const meta = await metadata(server); | |
| 174 | const id = credentials.clientId ?? clientId(redirectUri()); | |
| 175 | try { | |
| 176 | const tokens = await AuthSession.refreshAsync({ clientId: id, refreshToken: credentials.refreshToken }, discovery(meta)); | |
| 177 | return fromTokenResponse(tokens, id); | |
| 178 | } catch (error) { | |
| 179 | // invalid_grant: used, expired or revoked in Connected applications. | |
| 180 | if (error instanceof AuthSession.TokenError) throw new SignedOut(); | |
| 181 | throw error; | |
| 182 | } | |
| 183 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.