opencode-gemini-auth-proxy 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # Gemini OAuth Plugin for Opencode
2
+
3
+ ![License](https://img.shields.io/npm/l/opencode-gemini-auth)
4
+ ![Version](https://img.shields.io/npm/v/opencode-gemini-auth)
5
+
6
+ **Authenticate the Opencode CLI with your Google account.** This plugin enables
7
+ you to use your existing Gemini plan and quotas (including the free tier)
8
+ directly within Opencode, bypassing separate API billing.
9
+
10
+ ## Prerequisites
11
+
12
+ - [Opencode CLI](https://opencode.ai) installed.
13
+ - A Google account with access to Gemini.
14
+
15
+ ## Installation
16
+
17
+ Add the plugin to your Opencode configuration file
18
+ (`~/.config/opencode/opencode.json` or similar):
19
+
20
+ ```json
21
+ {
22
+ "$schema": "https://opencode.ai/config.json",
23
+ "plugin": ["opencode-gemini-auth@latest"]
24
+ }
25
+ ```
26
+
27
+ > [!IMPORTANT]
28
+ > If you're using a paid Gemini Code Assist subscription (Standard/Enterprise),
29
+ > explicitly configure a Google Cloud `projectId`. Free tier accounts should
30
+ > auto-provision a managed project, but you can still set `projectId` to force
31
+ > a specific project.
32
+
33
+ ## Usage
34
+
35
+ 1. **Login**: Run the authentication command in your terminal:
36
+
37
+ ```bash
38
+ opencode auth login
39
+ ```
40
+
41
+ 2. **Select Provider**: Choose **Google** from the list.
42
+ 3. **Authenticate**: Select **OAuth with Google (Gemini CLI)**.
43
+ - A browser window will open for you to approve the access.
44
+ - The plugin spins up a temporary local server to capture the callback.
45
+ - If the local server fails (e.g., port in use or headless environment),
46
+ you can manually paste the callback URL or just the authorization code.
47
+
48
+ Once authenticated, Opencode will use your Google account for Gemini requests.
49
+
50
+ ## Configuration
51
+
52
+ ### Google Cloud Project
53
+
54
+ By default, the plugin attempts to provision or find a suitable Google Cloud
55
+ project. To force a specific project, set the `projectId` in your configuration
56
+ or via environment variables:
57
+
58
+ **File:** `~/.config/opencode/opencode.json`
59
+
60
+ ```json
61
+ {
62
+ "provider": {
63
+ "google": {
64
+ "options": {
65
+ "projectId": "your-specific-project-id"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ You can also set `OPENCODE_GEMINI_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`, or
73
+ `GOOGLE_CLOUD_PROJECT_ID` to supply the project ID via environment variables.
74
+
75
+ ### Model list
76
+
77
+ Below are example model entries you can add under `provider.google.models` in your
78
+ Opencode config. Each model can include an `options.thinkingConfig` block to
79
+ enable "thinking" features.
80
+
81
+ ```json
82
+ {
83
+ "provider": {
84
+ "google": {
85
+ "models": {
86
+ "gemini-2.5-flash": {
87
+ "options": {
88
+ "thinkingConfig": {
89
+ "thinkingBudget": 8192,
90
+ "includeThoughts": true
91
+ }
92
+ }
93
+ },
94
+ "gemini-2.5-pro": {
95
+ "options": {
96
+ "thinkingConfig": {
97
+ "thinkingBudget": 8192,
98
+ "includeThoughts": true
99
+ }
100
+ }
101
+ },
102
+ "gemini-3-flash-preview": {
103
+ "options": {
104
+ "thinkingConfig": {
105
+ "thinkingLevel": "high",
106
+ "includeThoughts": true
107
+ }
108
+ }
109
+ },
110
+ "gemini-3-pro-preview": {
111
+ "options": {
112
+ "thinkingConfig": {
113
+ "thinkingLevel": "high",
114
+ "includeThoughts": true
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ Note: Available model names and previews may change—check Google's documentation or
125
+ the Gemini product page for the current model identifiers.
126
+
127
+ ### Thinking Models
128
+
129
+ The plugin supports configuring Gemini "thinking" features per-model via
130
+ `thinkingConfig`. The available fields depend on the model family:
131
+
132
+ - For Gemini 3 models: use `thinkingLevel` with values `"low"` or `"high"`.
133
+ - For Gemini 2.5 models: use `thinkingBudget` (token count).
134
+ - `includeThoughts` (boolean) controls whether the model emits internal thoughts.
135
+
136
+ A combined example showing both model types:
137
+
138
+ ```json
139
+ {
140
+ "provider": {
141
+ "google": {
142
+ "models": {
143
+ "gemini-3-pro-preview": {
144
+ "options": {
145
+ "thinkingConfig": {
146
+ "thinkingLevel": "high",
147
+ "includeThoughts": true
148
+ }
149
+ }
150
+ },
151
+ "gemini-2.5-flash": {
152
+ "options": {
153
+ "thinkingConfig": {
154
+ "thinkingBudget": 8192,
155
+ "includeThoughts": true
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ ```
164
+
165
+ If you don't set a `thinkingConfig` for a model, the plugin will use default
166
+ behavior for that model.
167
+
168
+ ## Troubleshooting
169
+
170
+ ### Manual Google Cloud Setup
171
+
172
+ If automatic provisioning fails, you may need to set up the project manually:
173
+
174
+ 1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
175
+ 2. Create or select a project.
176
+ 3. Enable the **Gemini for Google Cloud API**
177
+ (`cloudaicompanion.googleapis.com`).
178
+ 4. Configure the `projectId` in your Opencode config as shown above.
179
+
180
+ ### Quotas, Plans, and 429 Errors
181
+
182
+ Common causes of `429 RESOURCE_EXHAUSTED` or `QUOTA_EXHAUSTED`:
183
+
184
+ - **No project ID configured**: the plugin uses a managed free-tier project, which has lower quotas.
185
+ - **Model-specific limits**: quotas are tracked per model (e.g., `gemini-3-pro-preview` vs `gemini-3-flash-preview`).
186
+ - **Large prompts**: OAuth/Code Assist does not support cached content, so long system prompts and history can burn quota quickly.
187
+ - **Parallel sessions**: multiple Opencode windows can drain the same bucket.
188
+
189
+ Notes:
190
+
191
+ - **Gemini CLI auto-fallbacks**: the official CLI may fall back to Flash when Pro quotas are exhausted, so it can appear to “work” even if the Pro bucket is depleted.
192
+ - **Paid plans still require a project**: to use paid quotas in Opencode, set `provider.google.options.projectId` (or `OPENCODE_GEMINI_PROJECT_ID`) and re-authenticate.
193
+
194
+ ### Debugging
195
+
196
+ To view detailed logs of Gemini requests and responses, set the
197
+ `OPENCODE_GEMINI_DEBUG` environment variable:
198
+
199
+ ```bash
200
+ OPENCODE_GEMINI_DEBUG=1 opencode
201
+ ```
202
+
203
+ This will generate `gemini-debug-<timestamp>.log` files in your working
204
+ directory containing sanitized request/response details.
205
+
206
+ ## Parity Notes
207
+
208
+ This plugin mirrors the official Gemini CLI OAuth flow and Code Assist
209
+ endpoints. In particular, project onboarding and quota retry handling follow
210
+ the same behavior patterns as the Gemini CLI.
211
+
212
+ ### References
213
+
214
+ - Gemini CLI repository: https://github.com/google-gemini/gemini-cli
215
+ - Gemini CLI quota documentation: https://developers.google.com/gemini-code-assist/resources/quotas
216
+
217
+ ### Updating
218
+
219
+ Opencode does not automatically update plugins. To update to the latest version,
220
+ you must clear the cached plugin:
221
+
222
+ ```bash
223
+ # Clear the specific plugin cache
224
+ rm -rf ~/.cache/opencode/node_modules/opencode-gemini-auth
225
+
226
+ # Run Opencode to trigger a fresh install
227
+ opencode
228
+ ```
229
+
230
+ ## Development
231
+
232
+ To develop on this plugin locally:
233
+
234
+ 1. **Clone**:
235
+
236
+ ```bash
237
+ git clone https://github.com/jenslys/opencode-gemini-auth.git
238
+ cd opencode-gemini-auth
239
+ bun install
240
+ ```
241
+
242
+ 2. **Link**:
243
+ Update your Opencode config to point to your local directory using a
244
+ `file://` URL:
245
+
246
+ ```json
247
+ {
248
+ "plugin": ["file:///absolute/path/to/opencode-gemini-auth"]
249
+ }
250
+ ```
251
+
252
+ ## License
253
+
254
+ MIT
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export {
2
+ GeminiCLIOAuthPlugin,
3
+ GoogleOAuthPlugin,
4
+ } from "./src/plugin";
5
+
6
+ export {
7
+ authorizeGemini,
8
+ exchangeGeminiWithVerifier,
9
+ } from "./src/gemini/oauth";
10
+
11
+ export type {
12
+ GeminiAuthorization,
13
+ GeminiTokenExchangeResult,
14
+ } from "./src/gemini/oauth";
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "opencode-gemini-auth-proxy",
3
+ "module": "index.ts",
4
+ "version": "1.3.10",
5
+ "author": "jenslys",
6
+ "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
+ "files": [
8
+ "index.ts",
9
+ "src"
10
+ ],
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "devDependencies": {
14
+ "@opencode-ai/plugin": "^1.1.48",
15
+ "@types/bun": "latest"
16
+ },
17
+ "peerDependencies": {
18
+ "typescript": "^5.9.3"
19
+ },
20
+ "dependencies": {
21
+ "@openauthjs/openauth": "^0.4.3"
22
+ }
23
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Constants used for Google Gemini OAuth flows and Cloud Code Assist API integration.
3
+ */
4
+ export const GEMINI_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
5
+
6
+ /**
7
+ * Client secret issued for the Gemini CLI OAuth application.
8
+ */
9
+ export const GEMINI_CLIENT_SECRET = "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";
10
+
11
+ /**
12
+ * Scopes required for Gemini CLI integrations.
13
+ */
14
+ export const GEMINI_SCOPES: readonly string[] = [
15
+ "https://www.googleapis.com/auth/cloud-platform",
16
+ "https://www.googleapis.com/auth/userinfo.email",
17
+ "https://www.googleapis.com/auth/userinfo.profile",
18
+ ];
19
+
20
+ /**
21
+ * OAuth redirect URI used by the local CLI callback server.
22
+ */
23
+ export const GEMINI_REDIRECT_URI = "http://localhost:8085/oauth2callback";
24
+
25
+ /**
26
+ * Root endpoint for the Cloud Code Assist API which backs Gemini CLI traffic.
27
+ */
28
+ export const GEMINI_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
29
+
30
+ export const CODE_ASSIST_HEADERS = {
31
+ "User-Agent": "google-api-nodejs-client/9.15.1",
32
+ "X-Goog-Api-Client": "gl-node/22.17.0",
33
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
34
+ } as const;
35
+
36
+ /**
37
+ * Provider identifier shared between the plugin loader and credential store.
38
+ */
39
+ export const GEMINI_PROVIDER_ID = "google";
package/src/fetch.ts ADDED
@@ -0,0 +1,11 @@
1
+ export default (
2
+ input: RequestInfo,
3
+ init?: RequestInit
4
+ ): Promise<Response> =>
5
+ fetch(input, {
6
+ // https://bun.com/docs/guides/http/proxy
7
+ ...(process.env.OPENCODE_GEMINI_AUTH_PROXY
8
+ ? { proxy: process.env.OPENCODE_GEMINI_AUTH_PROXY }
9
+ : {}),
10
+ ...(init ?? {})
11
+ })
@@ -0,0 +1,178 @@
1
+ import proxyFetch from "../fetch";
2
+ import { generatePKCE } from "@openauthjs/openauth/pkce";
3
+ import { randomBytes } from "node:crypto";
4
+
5
+ import {
6
+ GEMINI_CLIENT_ID,
7
+ GEMINI_CLIENT_SECRET,
8
+ GEMINI_REDIRECT_URI,
9
+ GEMINI_SCOPES,
10
+ } from "../constants";
11
+ import {
12
+ formatDebugBodyPreview,
13
+ isGeminiDebugEnabled,
14
+ logGeminiDebugMessage,
15
+ } from "../plugin/debug";
16
+
17
+ interface PkcePair {
18
+ challenge: string;
19
+ verifier: string;
20
+ }
21
+
22
+ /**
23
+ * Result returned to the caller after constructing an OAuth authorization URL.
24
+ */
25
+ export interface GeminiAuthorization {
26
+ url: string;
27
+ verifier: string;
28
+ state: string;
29
+ }
30
+
31
+ interface GeminiTokenExchangeSuccess {
32
+ type: "success";
33
+ refresh: string;
34
+ access: string;
35
+ expires: number;
36
+ email?: string;
37
+ }
38
+
39
+ interface GeminiTokenExchangeFailure {
40
+ type: "failed";
41
+ error: string;
42
+ }
43
+
44
+ export type GeminiTokenExchangeResult =
45
+ | GeminiTokenExchangeSuccess
46
+ | GeminiTokenExchangeFailure;
47
+
48
+ interface GeminiTokenResponse {
49
+ access_token: string;
50
+ expires_in: number;
51
+ refresh_token: string;
52
+ }
53
+
54
+ interface GeminiUserInfo {
55
+ email?: string;
56
+ }
57
+
58
+ /**
59
+ * Build the Gemini OAuth authorization URL including PKCE.
60
+ */
61
+ export async function authorizeGemini(): Promise<GeminiAuthorization> {
62
+ const pkce = (await generatePKCE()) as PkcePair;
63
+ const state = randomBytes(32).toString("hex");
64
+
65
+ const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
66
+ url.searchParams.set("client_id", GEMINI_CLIENT_ID);
67
+ url.searchParams.set("response_type", "code");
68
+ url.searchParams.set("redirect_uri", GEMINI_REDIRECT_URI);
69
+ url.searchParams.set("scope", GEMINI_SCOPES.join(" "));
70
+ url.searchParams.set("code_challenge", pkce.challenge);
71
+ url.searchParams.set("code_challenge_method", "S256");
72
+ url.searchParams.set("state", state);
73
+ url.searchParams.set("access_type", "offline");
74
+ url.searchParams.set("prompt", "consent");
75
+ // Add a fragment so any stray terminal glyphs are ignored by the auth server.
76
+ url.hash = "opencode";
77
+
78
+ return {
79
+ url: url.toString(),
80
+ verifier: pkce.verifier,
81
+ state,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Exchange an authorization code using a known PKCE verifier.
87
+ */
88
+ export async function exchangeGeminiWithVerifier(
89
+ code: string,
90
+ verifier: string,
91
+ ): Promise<GeminiTokenExchangeResult> {
92
+ try {
93
+ return await exchangeGeminiWithVerifierInternal(code, verifier);
94
+ } catch (error) {
95
+ return {
96
+ type: "failed",
97
+ error: error instanceof Error ? error.message : "Unknown error",
98
+ };
99
+ }
100
+ }
101
+
102
+ async function exchangeGeminiWithVerifierInternal(
103
+ code: string,
104
+ verifier: string,
105
+ ): Promise<GeminiTokenExchangeResult> {
106
+ if (isGeminiDebugEnabled()) {
107
+ logGeminiDebugMessage("OAuth exchange: POST https://oauth2.googleapis.com/token");
108
+ }
109
+ const tokenResponse = await proxyFetch("https://oauth2.googleapis.com/token", {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/x-www-form-urlencoded",
113
+ },
114
+ body: new URLSearchParams({
115
+ client_id: GEMINI_CLIENT_ID,
116
+ client_secret: GEMINI_CLIENT_SECRET,
117
+ code,
118
+ grant_type: "authorization_code",
119
+ redirect_uri: GEMINI_REDIRECT_URI,
120
+ code_verifier: verifier,
121
+ }),
122
+ });
123
+
124
+ if (!tokenResponse.ok) {
125
+ const errorText = await tokenResponse.text();
126
+ if (isGeminiDebugEnabled()) {
127
+ logGeminiDebugMessage(
128
+ `OAuth exchange response: ${tokenResponse.status} ${tokenResponse.statusText}`,
129
+ );
130
+ const preview = formatDebugBodyPreview(errorText);
131
+ if (preview) {
132
+ logGeminiDebugMessage(`OAuth exchange error body: ${preview}`);
133
+ }
134
+ }
135
+ return { type: "failed", error: errorText };
136
+ }
137
+
138
+ const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
139
+ if (isGeminiDebugEnabled()) {
140
+ logGeminiDebugMessage(
141
+ `OAuth exchange success: expires_in=${tokenPayload.expires_in}s refresh_token=${tokenPayload.refresh_token ? "yes" : "no"}`,
142
+ );
143
+ }
144
+
145
+ if (isGeminiDebugEnabled()) {
146
+ logGeminiDebugMessage("OAuth userinfo: GET https://www.googleapis.com/oauth2/v1/userinfo");
147
+ }
148
+ const userInfoResponse = await proxyFetch(
149
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
150
+ {
151
+ headers: {
152
+ Authorization: `Bearer ${tokenPayload.access_token}`,
153
+ },
154
+ },
155
+ );
156
+ if (isGeminiDebugEnabled()) {
157
+ logGeminiDebugMessage(
158
+ `OAuth userinfo response: ${userInfoResponse.status} ${userInfoResponse.statusText}`,
159
+ );
160
+ }
161
+
162
+ const userInfo = userInfoResponse.ok
163
+ ? ((await userInfoResponse.json()) as GeminiUserInfo)
164
+ : {};
165
+
166
+ const refreshToken = tokenPayload.refresh_token;
167
+ if (!refreshToken) {
168
+ return { type: "failed", error: "Missing refresh token in response" };
169
+ }
170
+
171
+ return {
172
+ type: "success",
173
+ refresh: refreshToken,
174
+ access: tokenPayload.access_token,
175
+ expires: Date.now() + tokenPayload.expires_in * 1000,
176
+ email: userInfo.email,
177
+ };
178
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ accessTokenExpired,
5
+ formatRefreshParts,
6
+ isOAuthAuth,
7
+ parseRefreshParts,
8
+ } from "./auth";
9
+
10
+ describe("auth helpers", () => {
11
+ it("parses refresh parts with project and managed ids", () => {
12
+ const parts = parseRefreshParts("refresh|project-123|managed-456");
13
+ expect(parts.refreshToken).toBe("refresh");
14
+ expect(parts.projectId).toBe("project-123");
15
+ expect(parts.managedProjectId).toBe("managed-456");
16
+ });
17
+
18
+ it("formats refresh parts with optional segments", () => {
19
+ expect(formatRefreshParts({ refreshToken: "refresh" })).toBe("refresh");
20
+ expect(
21
+ formatRefreshParts({
22
+ refreshToken: "refresh",
23
+ projectId: "project-123",
24
+ }),
25
+ ).toBe("refresh|project-123|");
26
+ expect(
27
+ formatRefreshParts({
28
+ refreshToken: "refresh",
29
+ managedProjectId: "managed-456",
30
+ }),
31
+ ).toBe("refresh||managed-456");
32
+ });
33
+
34
+ it("detects OAuth auth payloads", () => {
35
+ expect(isOAuthAuth({ type: "oauth", refresh: "refresh" })).toBe(true);
36
+ expect(isOAuthAuth({ type: "api", refresh: "refresh" } as never)).toBe(false);
37
+ });
38
+
39
+ it("treats tokens near expiry as expired", () => {
40
+ const now = Date.now();
41
+ expect(
42
+ accessTokenExpired({
43
+ type: "oauth",
44
+ refresh: "refresh",
45
+ access: "access",
46
+ expires: now + 59_000,
47
+ }),
48
+ ).toBe(true);
49
+ expect(
50
+ accessTokenExpired({
51
+ type: "oauth",
52
+ refresh: "refresh",
53
+ access: "access",
54
+ expires: now + 61_000,
55
+ }),
56
+ ).toBe(false);
57
+ });
58
+ });
@@ -0,0 +1,46 @@
1
+ import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types";
2
+
3
+ const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
4
+
5
+ export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails {
6
+ return auth.type === "oauth";
7
+ }
8
+
9
+ /**
10
+ * Splits a packed refresh string into its constituent refresh token and project IDs.
11
+ */
12
+ export function parseRefreshParts(refresh: string): RefreshParts {
13
+ const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|");
14
+ return {
15
+ refreshToken,
16
+ projectId: projectId || undefined,
17
+ managedProjectId: managedProjectId || undefined,
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Serializes refresh token parts into the stored string format.
23
+ */
24
+ export function formatRefreshParts(parts: RefreshParts): string {
25
+ if (!parts.refreshToken) {
26
+ return "";
27
+ }
28
+
29
+ if (!parts.projectId && !parts.managedProjectId) {
30
+ return parts.refreshToken;
31
+ }
32
+
33
+ const projectSegment = parts.projectId ?? "";
34
+ const managedSegment = parts.managedProjectId ?? "";
35
+ return `${parts.refreshToken}|${projectSegment}|${managedSegment}`;
36
+ }
37
+
38
+ /**
39
+ * Determines whether an access token is expired or missing, with buffer for clock skew.
40
+ */
41
+ export function accessTokenExpired(auth: OAuthAuthDetails): boolean {
42
+ if (!auth.access || typeof auth.expires !== "number") {
43
+ return true;
44
+ }
45
+ return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS;
46
+ }