Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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