Skip to content
180 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 has no scope:
32 * see the README's "Chat and OAuth tokens".
33 */
34export 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
52/** Whether the app is running inside Expo Go, while developing. */
53export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
54
55/** The name shown on the approval page and in Connected applications. */
56const CLIENT_NAME = IN_EXPO_GO ? "g1t in Expo Go" : Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android";
57
58/**
59 * Where the site sends the person back: `g1t://oauth/callback` in a build
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.
65 */
66export function redirectUri(): string {
67 return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) });
68}
69
70export 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
76type 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. */
84async 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
92function 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
100export 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 */
111export 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 );
142 return fromTokenResponse(tokens, id);
143}
144
145function fromTokenResponse(tokens: AuthSession.TokenResponse, clientId: string): Credentials {
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 ?? "",
153 clientId,
154 };
155}
156
157/** Raised when the refresh token no longer works: the person must sign in again. */
158export 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 */
169export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> {
170 const meta = await metadata(server);
171 const id = credentials.clientId ?? clientId(redirectUri());
172 try {
173 const tokens = await AuthSession.refreshAsync({ clientId: id, refreshToken: credentials.refreshToken }, discovery(meta));
174 return fromTokenResponse(tokens, id);
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}