Skip to content
173 linesCodeBlameRaw
1import { encodeOAuthClient } from "@g1t/contracts/oauth";
2import * as AuthSession from "expo-auth-session";
3import * as WebBrowser from "expo-web-browser";
4import { Platform } from "react-native";
5
6import type { Credentials } from "./credentials";
7import { OAUTH_CALLBACK_PATH } from "./links";
8import 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.
26WebBrowser.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 */
33export 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. */
52const 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 */
58export function redirectUri(): string {
59 return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) });
60}
61
62export 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
68type 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. */
76async 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
84function 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
92export 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 */
103export 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
137function 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. */
149export 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 */
160export 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}