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

@wordup-md/auth

v0.2.0

Published

Universal OAuth 2.0 + PKCE login from the browser: GitLab, GitHub, Forgejo, Gitea, Google… with an optional token proxy for providers that require a client secret.

Readme

@wordup-md/auth

OAuth 2.0 login from the browser, the same API for GitLab, GitHub, Forgejo, Gitea and Google, part of the WordUp ecosystem.

Authorization code flow with PKCE (oauth4webapi), in a popup, with a redirect, or in the system browser for desktop apps. Tokens are refreshed when they expire, once across tabs. Every provider is its own subpath export: an app only bundles the providers it imports.

Install

pnpm add @wordup-md/auth

Providers

| Provider | Import | From the browser alone | Default scopes | | --------- | ------------------------- | ------------------------------ | ------------------------------------------- | | gitlab | @wordup-md/auth/gitlab | Yes | api | | forgejo | @wordup-md/auth/forgejo | Yes, with CORS on the instance | read:user write:repository | | gitea | @wordup-md/auth/gitea | Yes, with CORS on the instance | read:user write:repository | | github | @wordup-md/auth/github | No: needs the proxy | repo read:user | | google | @wordup-md/auth/google | No: needs the proxy | openid email profile + Drive drive.file | | oidc | @wordup-md/auth/oidc | Yes, with CORS on the provider | openid profile email offline_access |

  • GitLab: register the application with Confidential unchecked.
  • Forgejo ≥ 1.21.4 / Gitea ≥ 1.20: register it with Confidential client unchecked, and allow CORS in app.ini ([cors] ENABLED = true, ALLOW_DOMAIN = https://your-app).
  • GitHub requires the client secret even with PKCE, and its token endpoint has no CORS.
  • Google requires the client secret even with PKCE. Register a Web application client.
  • OpenID Connect (Authelia, Keycloak, Authentik, Zitadel…): see below.

Any other OAuth 2.0 server works through defineProvider().

Usage

import { createAuth } from "@wordup-md/auth";
import { gitlab } from "@wordup-md/auth/gitlab";

const auth = createAuth(gitlab({ clientId: "…", url: "https://gitlab.example.com" }), {
  redirectUri: "https://cms.example.com/auth/callback", // default: `${origin}/auth/callback`
});

loginButton.onclick = async () => {
  await auth.login(); // opens the popup, inside the click handler
  console.log(await auth.getUser()); // { id, login, name, email, avatarUrl, raw }
};

await auth.fetch("/projects?membership=true"); // relative to the provider API
await auth.logout({ revoke: true });

The callback page, at redirectUri, only needs:

import { handleCallback } from "@wordup-md/auth/callback";
handleCallback();

It sends its URL to the login window, through window.opener or a BroadcastChannel when a Cross-Origin-Opener-Policy cut the link, and closes. It holds no code verifier and needs no config.

Redirect mode

Where popups are blocked (in-app browsers), auth.login({ mode: "redirect" }) leaves the page, and the page at redirectUri finishes the login:

const session = await auth.handleRedirect(); // undefined when the page is no callback

External mode (desktop apps)

A desktop app logs in through the system browser (RFC 8252): open shows the provider page, and the app hands the callback URL, received through a deep link or a loopback server, to auth.handleRedirect(url). login() resolves once it does. With Tauri v2 and its deep-link and opener plugins:

import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { openUrl } from "@tauri-apps/plugin-opener";

const auth = createAuth(gitlab({ clientId: "…" }), {
  redirectUri: "wordup://auth/callback", // registered on the provider and in tauri.conf.json
});

// At startup: the link may also relaunch the app.
const finish = (urls: string[] | null) =>
  urls && auth.handleRedirect(urls[0]!).catch(console.error);
finish(await getCurrent());
await onOpenUrl(finish);

loginButton.onclick = () => auth.login({ mode: "external", open: openUrl });

The state and the code verifier are kept in storage until the callback, so a login started before a restart of the app still finishes; a callback with another state is refused and leaves the login waiting. login() rejects on timeout (timeout option, 10 minutes by default), aborted (signal option, or a new login) and the errors of the code exchange, which handleRedirect() rejects with as well.

For a provider that refuses custom schemes, a loopback server on a port chosen at runtime (tauri-plugin-oauth) takes the callback, with the redirectUri of the login:

import { onUrl, start } from "@fabianlars/tauri-plugin-oauth";

const port = await start();
await onUrl((url) => auth.handleRedirect(url));
await auth.login({
  mode: "external",
  open: openUrl,
  redirectUri: `http://127.0.0.1:${port}/callback`,
});

In an iframe

In a cross-origin iframe, the browser partitions storage and BroadcastChannel by top-level site: the callback page, opened in a first-party popup, cannot reach the iframe through the BroadcastChannel fallback, and the stored session is not the one of the app opened on its own. A provider sending Cross-Origin-Opener-Policy also cuts window.opener, so the popup looks closed at once: pass popup: { detectClose: false }. Better: let the top-level window own the login, and hand the access token to the iframe (postMessage, then auth.setToken() inside).

OpenID Connect

oidc() reads the endpoints from the discovery document of the issuer (/.well-known/openid-configuration), checks that it belongs to that issuer and supports PKCE with S256, then returns the provider. oidcProvider(metadata, options) does the same from a document at hand, without the request. The ID token of the login is checked against the issuer.

import { createAuth } from "@wordup-md/auth";
import { oidc } from "@wordup-md/auth/oidc";

const auth = createAuth(
  await oidc({
    issuer: "https://auth.example.com",
    clientId: "wordup",
    apiURL: "https://cloud.example.com/remote.php/dav", // what `auth.fetch()` paths resolve against
  }),
);

The access token then unlocks the apps that trust the provider: OpenCloud validates it against the userinfo endpoint, Nextcloud with user_oidc and its bearer check. Register a public client (token_endpoint_auth_method: none, PKCE required), with the offline_access scope and the refresh_token grant for a session that outlives the access token, and allow the origin of the app on the token endpoint (Authelia: allowed_origins). Options: issuer, clientId, id (storage key, default "oidc"), scopes, apiURL (default: the issuer), authorizationParams, requiresProxy for a confidential client, and fetch for the discovery request.

With the unstorage drivers

getHeaders() fits the getHeaders option of @wordup-md/unstorage-drivers: the token is refreshed before each request.

import gitlabDriver from "@wordup-md/unstorage-drivers/gitlab";

gitlabDriver({ repo: "group/site", getHeaders: auth.getHeaders });

API

| Member | Does | | ---------------------- | ----------------------------------------------------------------------------------------------- | | login(options?) | Logs in (mode, scopes, params, popup: { width, height, timeout, signal, detectClose }). | | handleRedirect(url?) | Finishes a redirect or external login. | | logout({ revoke? }) | Forgets the session, revokes the token on the provider when it can. | | setToken(token) | Uses a personal access token instead of logging in. | | getSession() | The stored session, as is. | | getAccessToken() | A valid access token, refreshed when it expires. | | getHeaders() | { Authorization: "Bearer …" }, or {} when logged out. | | fetch(input, init?) | fetch with the token; relative paths resolve against the provider API. | | getUser() | The logged in user, the same shape for every provider. | | onChange(callback) | Called on login, refresh and logout. Returns the unsubscribe function. |

Errors are AuthErrors with a code: popup_blocked, popup_closed, timeout, aborted, authorization_error, invalid_state, token_error, not_authenticated, proxy_required.

Options of createAuth

| Option | Default | | | ----------------------- | --------------------------------------- | --------------------------------------------------- | | redirectUri | ${location.origin}/auth/callback | Must match the one registered on the provider. | | tokenProxy | | URL of the token proxy for this provider. | | storage | localStorage, memory when unavailable | Sync or async getItem / setItem / removeItem. | | storageKey | wordup-auth:${id}:${url} | One session per provider instance. | | refreshMargin | 60 | Seconds before expiry from which to refresh. | | allowInsecureRequests | false | Allow http: endpoints, for a local forge. | | fetch | global fetch | |

Tokens live in localStorage by default, readable by any script of the page: an XSS can steal them. Pass another storage if that matters for your app.

Proxy

For GitHub and Google, a small server handler adds the client secret to the token requests. It takes a web standard Request and returns a Response, so it runs in Nitro/h3, Cloudflare, Vercel, Deno or Bun. The browser keeps the PKCE verifier: a stolen code is useless to whoever holds the proxy URL.

// server/routes/auth/token/[provider].post.ts (Nitro)
import { createTokenProxy } from "@wordup-md/auth/proxy";

const proxy = createTokenProxy({
  providers: {
    github: { clientId: "…", clientSecret: process.env.GITHUB_CLIENT_SECRET! },
    google: { clientId: "…", clientSecret: process.env.GOOGLE_CLIENT_SECRET! },
  },
  allowedOrigins: ["https://cms.example.com"], // cross-origin callers; same origin needs none
});

export default defineEventHandler((event) =>
  proxy(toWebRequest(event), getRouterParam(event, "provider")!),
);
const auth = createAuth(github({ clientId: "…" }), {
  tokenProxy: "https://cms.example.com/auth/token/github",
});

The proxy only forwards the authorization_code and refresh_token grants of the configured providers, checks the client_id, and turns GitHub errors sent with a 200 into a 400. Behind a reverse proxy where request.url is not the public origin, list your own origin in allowedOrigins.

Development

vp install
vp check   # format, lint, type check
vp test
vp pack    # build to dist/

License

MIT