Skip to content
183 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, 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 */
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 "agents:run",
51 "memory:read",
52 "artifacts:read",
53];
54
55/** Whether the app is running inside Expo Go, while developing. */
56export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
57
58/** The name shown on the approval page and in Connected applications. */
59const 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 */
69export function redirectUri(): string {
70 return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) });
71}
72
73export 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
79type 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. */
87async 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
95function 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
103export 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 */
114export 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
148function 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. */
161export 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 */
172export 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}