@byokit/connect 0.1.0 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 (2026-10-01)
6
+
7
+ - FIX: Preserve provider refresh-token lifetimes and sanitized OAuth error causes for setup guidance.
8
+ - Add a client-details check without a user grant, with plain outcomes for accepted, rejected and uncertain replies.
9
+
5
10
  ## 0.1.0 (2026-10-01)
6
11
 
7
12
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ import type { Keystore } from '@byokit/secrets';
24
24
  declare const store: Keystore;
25
25
  const drive = connect('drive', {
26
26
  store,
27
- person: 'person-42',
27
+ person: 'Umer',
28
28
  redirectUri: 'https://your-app.example/connect/callback',
29
29
  client: { id: 'your-google-client-id', secret: 'your-google-client-secret' },
30
30
  });
@@ -60,6 +60,37 @@ parameters. Options `scopes` override the preset/discovered scopes; security
60
60
  parameters (`state`, PKCE, response type, client and redirect) cannot be overridden
61
61
  through `extra`. A sign-in with explicitly incomplete granted scopes is not saved.
62
62
 
63
+ ## Check app details and inspect grant lifetime
64
+
65
+ ```ts
66
+ import type { Connection } from '@byokit/connect';
67
+
68
+ declare const drive: Connection;
69
+ declare const enteredClientId: string;
70
+ declare const enteredClientSecret: string;
71
+
72
+ const check = await drive.verifyClient(); // Uses options.client.
73
+ // Or check newly entered details without changing the saved connection:
74
+ const entered = await drive.verifyClient({ id: enteredClientId, secret: enteredClientSecret });
75
+ const grant = await drive.grant(); // Trusted app code only; contains credentials.
76
+ ```
77
+
78
+ `grant()` returns a snapshot or `null` and makes no provider call. Times are Unix
79
+ milliseconds. `refreshTokenExpiresIn` preserves the provider's
80
+ `refresh_token_expires_in` seconds; `refreshTokenExpiresAt` is its absolute deadline.
81
+ Sign-in/refresh responses calculate the deadline when received, and omitted refresh
82
+ lifetime fields retain the previous deadline. Expired refresh grants require
83
+ sign-in without sending another refresh request.
84
+
85
+ `verifyClient(client?)` uses the selected token endpoint with a deliberately
86
+ nonexistent refresh grant, without reading or changing the person's grant. For
87
+ Google, `invalid_client` yields `outcome: 'invalid'`; a 400 `invalid_grant` yields
88
+ `'valid'`; network failures and all other replies yield `'inconclusive'`. Each
89
+ outcome includes a plain `message`; failed provider replies may carry the same
90
+ sanitized diagnostic `cause` as `ConnectError`. A secret is required. This checks
91
+ client authentication only; it does not check consent-screen publishing, enabled
92
+ APIs, redirect registration or scopes. Tests use injected fake endpoints only.
93
+
63
94
  ## Loopback on a computer
64
95
 
65
96
  ```ts
@@ -70,7 +101,7 @@ declare const store: Keystore;
70
101
  declare const yourApp: { openExternal(url: string): Promise<void> };
71
102
 
72
103
  const attempt = await connectLoopback('notion', {
73
- store, person: 'person-42',
104
+ store, person: 'Umer',
74
105
  open: url => yourApp.openExternal(url),
75
106
  });
76
107
  await attempt.done;
@@ -91,7 +122,7 @@ import type { Keystore } from '@byokit/secrets';
91
122
  declare const store: Keystore;
92
123
 
93
124
  const notion = connect('notion', {
94
- store, person: 'person-42', redirectUri: 'https://your-app.example/connect/callback',
125
+ store, person: 'Umer', redirectUri: 'https://your-app.example/connect/callback',
95
126
  });
96
127
  // Sign in once, then reconstruct this handle across app restarts.
97
128
  const mcp = await notion.mcp({
@@ -131,7 +162,10 @@ failure; an expired token is never returned. Tokens without `expires_in` remain
131
162
  valid until rejected, then refresh if possible. No background timer is needed.
132
163
 
133
164
  `ConnectError.code` is a typed app diagnostic and `message` is a plain sentence.
134
- OAuth errors never include provider bodies, callback codes or tokens. No errors or
165
+ OAuth errors expose an optional typed `cause` with `error` and
166
+ `error_description`, sanitized against supplied/returned credentials and URLs.
167
+ The message remains a plain sentence. Treat provider descriptions as untrusted
168
+ text; they are diagnostics for the host, not ready-to-render UI. No errors or
135
169
  tokens are logged. MCP results/errors are private application content and should
136
170
  be treated accordingly. `fetch`, `now`, request and flow timeouts are injectable.
137
171
 
package/SECURITY.md CHANGED
@@ -7,8 +7,11 @@ server pool or account sharing is provided. Connection keys are fixed-length SHA
7
7
  resource, issuer/token endpoint and configured client ID. Long identities therefore
8
8
  fit every keystore backend without exposing those identities in keyring labels.
9
9
 
10
- Treat `token()` as trusted app code: never display or log its result. The kit does
11
- not log credentials or include provider response bodies in OAuth errors. The full
10
+ Treat `token()` and `grant()` as trusted app code: never display or log their results.
11
+ The kit does not log credentials or include raw provider response bodies in OAuth
12
+ errors. OAuth causes retain only `error` and `error_description`, with supplied and
13
+ returned credentials (including URL/form encodings), URLs and authorization headers
14
+ redacted. Treat descriptions as untrusted diagnostic text, never as HTML. The full
12
15
  MCP client can return private content and server error messages; handle those as
13
16
  private app data. Close each MCP client when finished.
14
17
 
package/dist/connect.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import { type ProviderId } from './providers.ts';
3
- import type { ConnectOptions, Provider, SignIn, McpOptions } from './types.ts';
3
+ import type { ConnectOptions, OAuthClient, Provider, SignIn, McpOptions, Grant, ClientVerification } from './types.ts';
4
4
  /** One person's connection on one device; the host owns the store and browser. */
5
5
  export declare class Connection {
6
6
  readonly provider: Readonly<Provider>;
@@ -14,6 +14,11 @@ export declare class Connection {
14
14
  private get timeout();
15
15
  private get now();
16
16
  private saved;
17
+ /** Snapshot for trusted app code, including the provider's refresh lifetime. */
18
+ grant(): Promise<Grant | null>;
19
+ /** Google checks app credentials before rejecting this deliberately nonexistent refresh grant.
20
+ * Only invalid_grant confirms the details; other responses cannot prove validity. */
21
+ verifyClient(input?: OAuthClient): Promise<ClientVerification>;
17
22
  connected(): Promise<boolean>;
18
23
  disconnect(): Promise<void>;
19
24
  /** Starts a fresh browser sign-in. No opt-in flag or server-held account is required. */
package/dist/connect.js CHANGED
@@ -1,6 +1,7 @@
1
+ import WORDS from './words.json' with { type: 'json' };
1
2
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
3
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
- import { ConnectError } from "./errors.js";
4
+ import { ConnectError, providerCause } from "./errors.js";
4
5
  import { discover } from "./discovery.js";
5
6
  import { endpoint, redirect, request, json } from "./http.js";
6
7
  import { providers } from "./providers.js";
@@ -26,6 +27,15 @@ function serial(slot, action) {
26
27
  }
27
28
  function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
28
29
  function random() { return base64url(crypto.getRandomValues(new Uint8Array(32))); }
30
+ function validClient(client) {
31
+ if (!client || typeof client.id !== 'string' || !client.id.trim() ||
32
+ (client.secret !== undefined && (typeof client.secret !== 'string' || !client.secret)) ||
33
+ (client.authMethod !== undefined && !['none', 'client_secret_post', 'client_secret_basic'].includes(client.authMethod)) ||
34
+ (client.authMethod?.startsWith('client_secret_') && !client.secret))
35
+ throw new ConnectError('configuration');
36
+ return { id: client.id, secret: client.secret, authMethod: client.authMethod };
37
+ }
38
+ function validTime(value) { return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value >= 0); }
29
39
  /** One person's connection on one device; the host owns the store and browser. */
30
40
  export class Connection {
31
41
  provider;
@@ -73,6 +83,36 @@ export class Connection {
73
83
  throw new ConnectError('token');
74
84
  }
75
85
  }
86
+ /** Snapshot for trusted app code, including the provider's refresh lifetime. */
87
+ async grant() {
88
+ const saved = await this.saved();
89
+ if (!saved)
90
+ return null;
91
+ const t = saved.tokens;
92
+ return { accessToken: t.access, refreshToken: t.refresh, expiresAt: t.expires, scope: t.scope,
93
+ refreshTokenExpiresIn: t.refreshExpiresIn, refreshTokenExpiresAt: t.refreshExpiresAt };
94
+ }
95
+ /** Google checks app credentials before rejecting this deliberately nonexistent refresh grant.
96
+ * Only invalid_grant confirms the details; other responses cannot prove validity. */
97
+ async verifyClient(input) {
98
+ const client = validClient((input ?? this.options.client));
99
+ if (!client.secret || client.authMethod === 'none')
100
+ throw new ConnectError('configuration');
101
+ try {
102
+ const info = await discover(this.provider, this.fetcher, this.timeout);
103
+ await this.exchange(info, client, { grant_type: 'refresh_token', refresh_token: 'byokit-client-check-not-a-grant' });
104
+ }
105
+ catch (error) {
106
+ if (!(error instanceof ConnectError))
107
+ throw error;
108
+ if (error.cause?.error === 'invalid_client' && (error.status === 400 || error.status === 401))
109
+ return { outcome: 'invalid', message: WORDS.verification.invalid, cause: error.cause };
110
+ if (error.cause?.error === 'invalid_grant' && error.status === 400)
111
+ return { outcome: 'valid', message: WORDS.verification.valid };
112
+ return { outcome: 'inconclusive', message: WORDS.verification.inconclusive, cause: error.cause };
113
+ }
114
+ return { outcome: 'inconclusive', message: WORDS.verification.inconclusive };
115
+ }
76
116
  async connected() { return !!await this.saved(); }
77
117
  async disconnect() {
78
118
  ++this.slot.epoch;
@@ -122,7 +162,7 @@ export class Connection {
122
162
  throw new ConnectError('callback');
123
163
  consumed = true;
124
164
  if (returned.searchParams.has('error'))
125
- throw new ConnectError('declined');
165
+ throw new ConnectError('declined', undefined, providerCause(Object.fromEntries(returned.searchParams), [state, verifier, client.id, client.secret ?? '', ...returned.searchParams.getAll('code')]));
126
166
  if (returned.searchParams.getAll('code').length !== 1 || !returned.searchParams.get('code'))
127
167
  throw new ConnectError('callback');
128
168
  await serial(this.slot, async () => {
@@ -148,11 +188,9 @@ export class Connection {
148
188
  method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
149
189
  body: JSON.stringify({ client_name: this.options.clientName ?? 'BYOKit', redirect_uris: [this.options.redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }),
150
190
  }, this.timeout);
151
- if (!response.ok) {
152
- await response.body?.cancel();
153
- throw new ConnectError('registration', response.status);
154
- }
155
191
  const m = await json(response, 'registration');
192
+ if (!response.ok || m.error)
193
+ throw new ConnectError('registration', response.status, providerCause(m, [this.options.clientName ?? 'BYOKit', this.options.redirectUri]));
156
194
  if (typeof m.client_id !== 'string' || !m.client_id || (m.token_endpoint_auth_method !== undefined && m.token_endpoint_auth_method !== 'none'))
157
195
  throw new ConnectError('registration');
158
196
  return { id: m.client_id, authMethod: 'none' };
@@ -172,15 +210,20 @@ export class Connection {
172
210
  const m = await json(response, 'token');
173
211
  if (!response.ok || m.error) {
174
212
  // Only an explicit invalid grant proves revocation. Transient failures preserve the sign-in.
213
+ const cause = providerCause(m, [client.id, client.secret ?? '', previous?.access ?? '', previous?.refresh ?? '', ...Object.values(fields)]);
175
214
  if (m.error === 'invalid_grant' && previous)
176
- throw new ConnectError('signin', response.status);
177
- throw new ConnectError('token', response.status);
215
+ throw new ConnectError('signin', response.status, cause);
216
+ throw new ConnectError('token', response.status, cause);
178
217
  }
179
218
  if (typeof m.access_token !== 'string' || !m.access_token || typeof m.token_type !== 'string' || m.token_type.toLowerCase() !== 'bearer' || (m.expires_in !== undefined && (!Number.isFinite(Number(m.expires_in)) || Number(m.expires_in) < 0)))
180
219
  throw new ConnectError('token');
220
+ if (m.refresh_token_expires_in !== undefined && (!validTime(m.refresh_token_expires_in) || !Number.isFinite(this.now + Number(m.refresh_token_expires_in) * 1000)))
221
+ throw new ConnectError('token');
181
222
  return { access: m.access_token, refresh: typeof m.refresh_token === 'string' ? m.refresh_token : previous?.refresh,
182
223
  expires: m.expires_in === undefined ? undefined : this.now + Number(m.expires_in) * 1000,
183
- scope: typeof m.scope === 'string' ? m.scope : previous?.scope };
224
+ scope: typeof m.scope === 'string' ? m.scope : previous?.scope,
225
+ refreshExpiresIn: typeof m.refresh_token_expires_in === 'number' ? m.refresh_token_expires_in : previous?.refreshExpiresIn,
226
+ refreshExpiresAt: typeof m.refresh_token_expires_in === 'number' ? this.now + m.refresh_token_expires_in * 1000 : previous?.refreshExpiresAt };
184
227
  }
185
228
  /** For trusted app code (mail/calendar/API calls). Never display or log this value. */
186
229
  async token(rejectedAccessToken) {
@@ -202,6 +245,11 @@ export class Connection {
202
245
  return old.access;
203
246
  throw new ConnectError('signin');
204
247
  }
248
+ if (old.refreshExpiresAt !== undefined && old.refreshExpiresAt <= this.now) {
249
+ if (!force && old.expires !== undefined && old.expires > this.now)
250
+ return old.access;
251
+ throw new ConnectError('signin');
252
+ }
205
253
  try {
206
254
  const tokens = await this.exchange(saved, saved.client, { grant_type: 'refresh_token', refresh_token: old.refresh }, old);
207
255
  if (epoch !== this.slot.epoch)
package/dist/errors.d.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  export type ConnectErrorCode = 'configuration' | 'discovery' | 'registration' | 'callback' | 'expired' | 'declined' | 'scope' | 'token' | 'signin' | 'network';
2
- /** Never includes provider bodies, callback codes, credentials or URLs. */
2
+ export interface ProviderErrorCause {
3
+ readonly error: string;
4
+ readonly error_description?: string;
5
+ }
6
+ /** Preserve OAuth diagnostics, removing credentials supplied to or returned by the provider. */
7
+ export declare function providerCause(body: Record<string, unknown>, secrets: readonly string[]): ProviderErrorCause | undefined;
8
+ /** Plain message and optional sanitized OAuth cause; never includes raw response bodies. */
3
9
  export declare class ConnectError extends Error {
4
10
  readonly code: ConnectErrorCode;
5
11
  readonly status?: number;
6
- constructor(code: ConnectErrorCode, status?: number);
12
+ readonly cause?: ProviderErrorCause;
13
+ constructor(code: ConnectErrorCode, status?: number, cause?: ProviderErrorCause);
7
14
  }
package/dist/errors.js CHANGED
@@ -1,11 +1,31 @@
1
1
  import WORDS from './words.json' with { type: 'json' };
2
2
  const words = WORDS.errors;
3
- /** Never includes provider bodies, callback codes, credentials or URLs. */
3
+ /** Preserve OAuth diagnostics, removing credentials supplied to or returned by the provider. */
4
+ export function providerCause(body, secrets) {
5
+ if (typeof body.error !== 'string')
6
+ return undefined;
7
+ const values = [...secrets, ...['access_token', 'refresh_token', 'id_token', 'client_secret', 'client_id'].map(k => body[k]).filter((v) => typeof v === 'string')];
8
+ const redact = (text) => {
9
+ for (const value of values.filter(Boolean).sort((a, b) => b.length - a.length)) {
10
+ const variants = new Set([value, new URLSearchParams({ v: value }).toString().slice(2)]);
11
+ try {
12
+ variants.add(encodeURIComponent(value));
13
+ }
14
+ catch { /* Malformed Unicode still gets literal/form redaction. */ }
15
+ for (const encoded of variants)
16
+ text = text.split(encoded).join('[redacted]');
17
+ }
18
+ return text.replace(/https?:\/\/[^\s<>"']+/gi, '[redacted]').replace(/(?:Bearer|Basic)\s+[^\s,;]+/gi, '[redacted]');
19
+ };
20
+ const standard = ['invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'access_denied', 'server_error', 'temporarily_unavailable', 'invalid_client_metadata', 'invalid_redirect_uri'];
21
+ return Object.freeze({ error: standard.includes(body.error) && !values.includes(body.error) ? body.error : redact(body.error), ...(typeof body.error_description === 'string' ? { error_description: redact(body.error_description) } : {}) });
22
+ }
23
+ /** Plain message and optional sanitized OAuth cause; never includes raw response bodies. */
4
24
  export class ConnectError extends Error {
5
25
  code;
6
26
  status;
7
- constructor(code, status) {
8
- super(words[code]);
27
+ constructor(code, status, cause) {
28
+ super(words[code], cause ? { cause } : undefined);
9
29
  this.name = 'ConnectError';
10
30
  this.code = code;
11
31
  this.status = status;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { connect, Connection } from './connect.ts';
2
2
  export { providers } from './providers.ts';
3
3
  export type { ProviderId } from './providers.ts';
4
4
  export { ConnectError } from './errors.ts';
5
- export type { ConnectErrorCode } from './errors.ts';
6
- export type { Provider, OAuthClient, OAuthEndpoints, ConnectOptions, SignIn, McpOptions } from './types.ts';
5
+ export type { ConnectErrorCode, ProviderErrorCause } from './errors.ts';
6
+ export type { Provider, OAuthClient, OAuthEndpoints, ConnectOptions, SignIn, McpOptions, Grant, ClientVerification } from './types.ts';
7
7
  export { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
8
  export * from '@modelcontextprotocol/sdk/types.js';
package/dist/types.d.ts CHANGED
@@ -53,3 +53,26 @@ export interface McpOptions {
53
53
  /** Register sampling/elicitation/request/notification handlers before connecting. */
54
54
  configure?: (client: import('@modelcontextprotocol/sdk/client/index.js').Client) => void;
55
55
  }
56
+ /** Trusted app data only: never display or log credentials. Times are Unix milliseconds. */
57
+ export interface Grant {
58
+ accessToken: string;
59
+ refreshToken?: string;
60
+ expiresAt?: number;
61
+ scope?: string;
62
+ /** The provider's refresh_token_expires_in value, in seconds. */
63
+ refreshTokenExpiresIn?: number;
64
+ /** Absolute deadline; unlike the duration, this does not restart on restore. */
65
+ refreshTokenExpiresAt?: number;
66
+ }
67
+ export type ClientVerification = {
68
+ outcome: 'valid';
69
+ message: string;
70
+ } | {
71
+ outcome: 'invalid';
72
+ message: string;
73
+ cause?: import('./errors.ts').ProviderErrorCause;
74
+ } | {
75
+ outcome: 'inconclusive';
76
+ message: string;
77
+ cause?: import('./errors.ts').ProviderErrorCause;
78
+ };
package/dist/words.json CHANGED
@@ -17,5 +17,10 @@
17
17
  "finishing": "Sign-in is already finishing. Return to the app.",
18
18
  "connected": "Connected. You can return to the app.",
19
19
  "failed": "Sign-in did not finish. Return to the app and try again."
20
+ },
21
+ "verification": {
22
+ "valid": "Your app details work.",
23
+ "invalid": "Check your app details and try again.",
24
+ "inconclusive": "Your app details could not be checked. Try again later."
20
25
  }
21
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/connect",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Connect a person's third-party apps with PKCE, refresh and typed remote MCP.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",