@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.
Maintainers
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/authProviders
| 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 callbackExternal 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
localStorageby default, readable by any script of the page: an XSS can steal them. Pass anotherstorageif 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
