npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@withone/connect

v0.8.3

Published

Frontend bindings for One Connect, a drop-in OAuth flow that lets your users grant your application scoped, revocable access to their own connected tools. The user owns the connections; you hold a token scoped to exactly what they granted.

Downloads

371

Readme

One Connect lets your users grant your application scoped, revocable access to their own One-connected tools — Gmail, Slack, Notion, Stripe and 500+ more — through the standard OAuth 2.1 authorization-code flow with PKCE.

Your user owns their connections inside One. You never see their Gmail password — you hold a One access token scoped to exactly what they granted, and they can narrow or revoke it at any time. One enforces the grant on every call.

Fully compatible with popular frameworks such as React, Next.js, Vue, Svelte, and more.

Connect vs. Auth@withone/auth puts connections in your One project (you own them). Connect puts connections in your user's own One account and hands you a scoped grant.

Install

With npm:

npm i @withone/connect

With yarn:

yarn add @withone/connect

How it works

Everything sensitive — state, the PKCE verifier, your client secret, the tokens — lives on your server. The SDK is a thin navigator: it sends the tab to One's hosted connect page and never touches a token.

You build exactly two backend routes and one button.

sequenceDiagram
    participant User
    participant YourApp as Your Application
    participant YourBackend as Your Backend
    participant One as One Connect

    User->>YourApp: Clicks "Connect your tools"
    YourApp->>YourBackend: Navigate tab → GET /api/one/authorize
    YourBackend->>YourBackend: Mint state + PKCE, set httpOnly cookie
    YourBackend->>One: 302 → /oauth/authorize
    User->>One: Sign in, connect tools, narrow & grant access
    One->>YourBackend: 302 → /api/one/callback?code&state
    YourBackend->>YourBackend: Verify state
    YourBackend->>One: POST /oauth/token (code + verifier + secret)
    One->>YourBackend: Access token + refresh token
    YourBackend->>YourApp: 302 → /?one_connect=success
    YourApp->>User: SDK detects the return, onSuccess() fires

The SDK watches for ?one_connect= on the page URL when the tab returns — so there is no completion page to build.

1 · Create your OAuth app

Dashboard → Settings → OAuth Apps → New OAuth app.

  • You get a Client ID (public) and a Client Secret (shown once — server-only, never in a browser).
  • Register your redirect URI (e.g. https://yourapp.com/api/one/callback).
  • Pick the access-token lifetime: 7 days, 30 days, 90 days or 1 year.
  • Optionally create a permission set — the connectors your app needs and the access level for each (full / read & write / read only / specific actions). Your user sees it pre-filled at consent and can only narrow it. Without one, your app asks for access to the user's connections generally, which they can also narrow.

Environment Variables

ONE_CLIENT_ID=...
ONE_CLIENT_SECRET=one_secret_...
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
ONE_PERMISSION_SET=79659c66-...

| Variable | Required | Description | |---|---|---| | ONE_CLIENT_ID | Yes | Public client ID from your OAuth app | | ONE_CLIENT_SECRET | Yes | Server-only secret. Never ship to a browser. | | ONE_REDIRECT_URI | Yes | Must exactly match the URI registered in the dashboard | | ONE_PERMISSION_SET | No | Pre-fills the consent screen with the access your app needs |

2 · Using the Connect component

Replace the authorize URL with your backend authorize endpoint.

Relative paths like /api/one/authorize work — the SDK resolves them against your page's origin. A full URL is only required if the authorize route lives on a different origin than the page.

"use client";

import { useOneConnect } from "@withone/connect";

export function ConnectWithOne() {
  const { open } = useOneConnect({
    authorize: {
      url: "https://your-domain.com/api/one/authorize",
    },
    appTheme: "light",
    onSuccess: () => {
      // Your backend already stored the tokens by the time this fires.
      console.log("Access granted");
    },
    onError: (error) => {
      console.error("Connect failed:", error);
    },
    onClose: () => {
      console.log("Connect flow closed");
    },
  });

  return <button onClick={open}>Connect your tools</button>;
}

Configuration Options

| Option | Type | Description | |---|---|---| | authorize.url | string | Full URL of your backend authorize endpoint. Must be absolute. | | appTheme | "dark" \| "light" | Theme for the Connect card. The SDK carries it on the URL fragment — nothing for your backend to forward. | | onSuccess | () => void | The grant completed and your server stored the tokens | | onError | (error: string) => void | The flow failed, with a human-readable message | | onClose | () => void | The user closed the card without a result |

Returned handle

| Method | Description | |---|---| | open() | Navigates the tab to One's hosted connect flow | | close() | No-op kept for API stability — safe to call on unmount |

Optional: the pre-built button

Any element wired to open() works — the button is optional. It ships as a custom element, <one-connect-button>, so the SAME tag works in React, Next, Vue, Svelte, or plain HTML — no refs, no mount calls. Importing the package registers it. It wires the whole flow itself and manages Connect → Connecting → Connected.

// React / Next — a real component:
import { ConnectButton } from "@withone/connect/react";

export function ConnectWithOne() {
  return (
    <ConnectButton
      authorizeUrl="/api/one/authorize"
      label="Connect your apps"
      platforms={[
        { name: "Stripe", imageUrl: "/icons/stripe.svg" },
        { name: "PostHog", imageUrl: "/icons/posthog.svg" },
      ]}
      onSuccess={() => {/* tokens stored server-side — refresh app state */}}
    />
  );
}
<!-- Vue 3 -->
<script setup>
import { ConnectButton } from "@withone/connect/vue";
</script>
<template>
  <ConnectButton
    authorize-url="/api/one/authorize"
    :platforms="[{ name: 'Stripe', imageUrl: '/icons/stripe.svg' }]"
    @success="onConnected"
  />
</template>
<!-- Svelte (an action — the idiomatic Svelte shape) -->
<script>
  import { connectButton } from "@withone/connect/svelte";
</script>
<div use:connectButton={{
  authorizeUrl: "/api/one/authorize",
  platforms: [{ name: "Stripe", imageUrl: "/icons/stripe.svg" }],
  onSuccess: () => { /* refresh app state */ },
}} />

Plain HTML (or any other framework) uses the SAME widget as a custom element — import "@withone/connect" registers <one-connect-button>:

<!-- Plain HTML / any framework -->
<one-connect-button
  authorize-url="/api/one/authorize"
  label="Connect your apps"
></one-connect-button>
<script>
  document.querySelector("one-connect-button")
    .addEventListener("success", () => location.reload());
</script>

| Attribute | What it does | |---|---| | authorize-url | Your backend authorize route (required) | | label | Button text (default "Connect your apps") | | variant | default pill · accent brand pill · block consent card | | theme | light / dark — matches YOUR page | | app-theme | Theme for One's card | | platforms | JSON array of {name, imageUrl} — provider chips, fan on hover | | more-count | The +N chip (e.g. 274) | | description | Sub-line on the block variant | | accent-color | Fill for the accent variant (lime fallback) | | connected-label | Label after success |

Events: success, error (detail = message), close — or set the onSuccess / onError / onClose function props (React 19, Vue and Svelte set these naturally).

TypeScript + React: add this once so JSX accepts the tag:

// one-connect-button.d.ts
declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "one-connect-button": React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement>,
        HTMLElement
      > & {
        "authorize-url"?: string; "app-theme"?: string; label?: string;
        variant?: string; theme?: string; platforms?: string;
        "more-count"?: string; description?: string;
        "accent-color"?: string; "connected-label"?: string;
        onSuccess?: () => void; onError?: (e: string) => void;
        onClose?: () => void;
      };
    }
  }
}
export {};

Programmatic alternative: mountConnectButton(container, options) takes the same options as an object (plus connect: {…useOneConnect props}) and returns { setState, destroy }.

3 · Backend — the authorize route

Generates state (CSRF proof) and PKCE (proof that whoever redeems the code is this server), stashes both in an httpOnly cookie, and 302s the browser to One.

// app/api/one/authorize/route.ts (Next.js App Router)
import { createHash, randomBytes } from "crypto";
import { NextRequest, NextResponse } from "next/server";

const ONE_AUTHORIZE_URL = "https://api.withone.ai/oauth/authorize";

export async function GET(req: NextRequest) {
  const state = randomBytes(16).toString("hex");
  const verifier = randomBytes(32).toString("base64url");
  const challenge = createHash("sha256").update(verifier).digest("base64url");

  const url = new URL(ONE_AUTHORIZE_URL);
  url.searchParams.set("client_id", process.env.ONE_CLIENT_ID!);
  url.searchParams.set("redirect_uri", process.env.ONE_REDIRECT_URI!);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", "user:connections:read user:connections:write org:connections:read org:connections:write project:connections:read project:connections:write"); // all 3 tenancy tiers — org/project grants 403 without theirs
  url.searchParams.set("state", state);
  url.searchParams.set("code_challenge", challenge);
  url.searchParams.set("code_challenge_method", "S256");

  if (process.env.ONE_PERMISSION_SET) {
    url.searchParams.set("permission_set", process.env.ONE_PERMISSION_SET);
  }

  // Optional: your user's email. One pre-fills (never locks) their sign-in.
  const userEmail = await getCurrentUserEmail(req); // ← your code
  if (userEmail) url.searchParams.set("login_hint", userEmail);

  const res = NextResponse.redirect(url.toString(), 302);
  // One cookie PER flow — the name carries the state. Users open the
  // flow more than once (retries, second tabs); a single shared cookie
  // would be overwritten by each start, so only the LAST-opened flow
  // could ever complete. Expiry reaps the strays.
  res.cookies.set(`one_tx_${state}`, verifier, {
    httpOnly: true,
    secure: true,
    // The callback is a TOP-LEVEL navigation on your own site, so Lax
    // survives the cross-site redirect chain (One -> here).
    sameSite: "lax",
    maxAge: 600, // matches One's 10-minute authorization-code lifetime
    path: "/api/one",
  });
  return res;
}

4 · Backend — the callback route

One redirects back with a single-use code, worthless without your secret and the PKCE verifier. Exchange it server-side, store the tokens, then redirect anywhere on your site with ?one_connect=success appended — the SDK reads that parameter off the page URL on return.

// app/api/one/callback/route.ts
import { NextRequest, NextResponse } from "next/server";

const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";

export async function GET(req: NextRequest) {
  const code = req.nextUrl.searchParams.get("code");
  const state = req.nextUrl.searchParams.get("state");
  // The state that came back selects its own cookie — not finding one
  // IS the CSRF failure (forged or stale state has no cookie).
  const verifier = state ? req.cookies.get(`one_tx_${state}`)?.value : undefined;

  if (!code || !state || !verifier) {
    return NextResponse.redirect(
      new URL(
        "/?one_connect=error&one_connect_message=" +
          encodeURIComponent("The sign-in attempt expired or was tampered with."),
        req.url,
      ),
      302,
    );
  }

  const basic = Buffer.from(
    `${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
  ).toString("base64");

  const tokenRes = await fetch(ONE_TOKEN_URL, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.ONE_REDIRECT_URI!,
      code_verifier: verifier,
    }),
  });

  const res = NextResponse.redirect(
    new URL(
      tokenRes.ok
        ? "/?one_connect=success"
        : "/?one_connect=error&one_connect_message=" +
          encodeURIComponent("Token exchange failed."),
      req.url,
    ),
    302,
  );
  res.cookies.delete(`one_tx_${state}`);

  if (tokenRes.ok) {
    // { access_token, refresh_token, token_type: "bearer", expires_in, scope }
    const tokens = await tokenRes.json();
    await saveOneTokens(req, {                    // ← your code
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      expiresAt: Date.now() + tokens.expires_in * 1000,
    });
  }
  return res;
}

Token response (200):

{
  "access_token": "one_at_...",
  "refresh_token": "one_rt_...",
  "token_type": "bearer",
  "expires_in": 2592000,
  "scope": "user:connections:read user:connections:write"
}

5 · Backend — refreshing the token

Access tokens last as long as you chose when creating the app (7 days to 1 year). Refresh tokens last 30 days and are rotated on every use — always store BOTH new tokens. Reusing an old refresh token revokes the entire token family (theft protection).

const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";

export async function getOneAccessToken(userId: string): Promise<string> {
  const t = await loadOneTokens(userId);           // ← your code
  if (Date.now() < t.expiresAt - 60_000) return t.accessToken;

  // The refresh exchange is authenticated exactly like the code exchange —
  // same Basic header. The public-client form (client_id in the body, no
  // secret) is rejected with 401.
  const basic = Buffer.from(
    `${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
  ).toString("base64");

  const res = await fetch(ONE_TOKEN_URL, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: t.refreshToken,
    }),
  });
  if (!res.ok) throw new Error("One refresh failed — re-run the connect flow");

  const tokens = await res.json();
  await saveOneTokens(userId, {                    // BOTH tokens — rotation!
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  });
  return tokens.access_token;
}

6 · Backend — using the grant

The bearer token works on One's standard /v1 API — the same routes every other credential uses.

const token = await getOneAccessToken(userId);

// Discover what the user granted. Ungranted connections are invisible,
// not merely forbidden.
const res = await fetch("https://api.withone.ai/v1/connections", {
  headers: { Authorization: `Bearer ${token}` },
});

Execute actions through /v1/passthrough/* with the same bearer. Every call is checked inside One against what the user granted — a call outside the grant returns 403, and your code cannot override it. That is the point.

Completion

There is no completion page to build: your callback's final redirect carries ?one_connect=success on any same-origin URL. The SDK detects it, fires your callbacks, and scrubs the params from the address bar. The SDK paints no result UI of its own — One's hosted page already showed the success beat before redirecting back. If you want your own celebratory screen, just have the callback redirect there; append the ?one_connect= params wherever the SDK is mounted.

What your users see

In their own One dashboard, your app appears under Authorized apps with what they granted and when it was last used. They can revoke it at any time — handle 401/403 by prompting them to reconnect.

In your dashboard, your OAuth app lists every user who granted access, and you can revoke individual users too.

Security notes

  • The client secret lives on your server only. Authenticate the token exchange with the Authorization: Basic header, as shown above.
  • The authorization code is single-use and expires in 10 minutes.
  • The SDK never handles tokens. It navigates the tab to One's hosted connect page and watches for the ?one_connect= result on the way back — there is nothing sensitive in the browser to leak.

License

This project is licensed under the GPL-3.0 license. See the LICENSE file for details.