Skip to content
sandros94Public

About

A collection of low-level and high-level, server-agnostic, Authentication and Authorization utilities.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

unauth

npm version npm downloads bundle size

A collection of low-level and high-level, server-agnostic, Authentication and Authorization (soon) utilities.

Warning

This package is in active development. It is not recommended for production use yet unless you are willing to help with testing and feedback. Expect breaking changes.

Features

  • OAuth 2.1 and OpenID Connect — one handler for both legs of a sign-in, presets for GitHub, Google, Apple, Microsoft, Discord, GitLab, LinkedIn, Facebook, Twitch, X, Spotify, TikTok, Netlify, Vercel and Cloudflare Access, generic defineOAuth2Provider and defineOidcProvider
  • AT Protocol — sign in with any PDS account: handle resolution, PAR, PKCE, DPoP, the account verified against its PDS automatically
  • Custom providers — any protocol over the same engine, with start and complete legs
  • One-time tokens — signed, single-use, expiring tokens
  • Web-standard — handlers take a request event { req, res: { headers } }; startFlow and completeFlow take a Request
  • unjwt included — unauth/jwk, unauth/jws, unauth/jwe, unauth/utils, unauth/session and unauth/dpop are unjwt's subpaths, one dependency to install

Built on:

  • unjwt — JWT (JWS/JWE/JWK) and cookie sessions via Web Crypto
  • unsecure — cryptographic utilities

Usage

npx nypm install unauth

Session

The session is the application's own unauth/session definition (unjwt's defineJWESession, re-exported). A sign-in writes to it in onSuccess.

import { defineJWESession } from "unauth/session";

const session = defineJWESession<SessionData, "1h">({ key, maxAge: "1h", rolling: "15m" });
export const useAuthSession = (event: WebSessionEvent) => session.use(event);

Sign in with a provider

One route serves both legs: without code or error it redirects to the provider with a sealed flow cookie; with one it completes the callback and calls onSuccess with the proven identity.

import type { H3Event } from "h3";
import { deriveJWK } from "unauth/n";
import { defineGitHubHandler } from "unauth/n/providers/github";

app.get(
  "/auth/github",
  defineGitHubHandler({
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    key: () => deriveJWK(process.env.AUTH_SECRET!, { info: "unauth:flow" }),
    origin: "https://app.example",
    onSuccess: async (event: H3Event, { identity }) => {
      const session = await useAuthSession(event);
      await session.clear();
      await session.update({ provider: identity.provider, subject: identity.subject });
    },
  }),
);
  • key: the key that seals the sign-in's flow cookie, or a callback returning it, called once on the first request. deriveJWK(secret, { info }) turns a random secret of 32+ characters into one. The shapes a key may take, rotation and the jwe and onKeyLookup options follow unauth/session's JWE session keys; a key pair is refused, since a token sealed to a public key could be minted by anyone holding it.
  • origin: the callback is this route on that origin, and its forms may start a sign-in that links an account or names it; a request on any other origin is refused with a 421. redirectUri names the callback outright instead. With origin too, both are where the sign-in runs.
  • onStart(event): optional; returns { redirect, memo, loginHint, scopes, params } for this request. memo is anything the application wants back at the callback, sealed; loginHint names the account (OAuth's login_hint, an AT Protocol handle). A start that returns either memo, or loginHint for a custom provider, is taken only as a POST from the application's own origin (ERR_AUTHN_CROSS_SITE_START otherwise): start those sign-ins from a form.
  • onSuccess(event, result): required; result holds identity, tokens, memo and redirect. Returning nothing answers a 302 to result.redirect, else the redirect option, else /; returning a Response or a value answers that.
  • fetch: optional; replaces the global fetch for every request a sign-in makes to a provider, where a deployment refuses private network ranges.
  • onError(event, error): optional; error is an UnauthError with code, cause and status, an UnauthError thrown by onStart or onSuccess included. Without it the failure is answered with a plain Response of that status.
  • Annotate the event of the first hook you write with the framework's event type: every hook and the handler take it, result and memo stay inferred.
  • defineSignInHandler(provider, options) is the same handler for any provider. It answers GET and POST and refuses other methods with a 405: mount it without a method filter (app.all(...), a routes/auth/github.ts rather than .get.ts).

Linking another account

onStart notes the signed-in user in memo; it comes back as result.memo, sealed, and onSuccess decides what to do with it. Such a start comes from a form on the application's own pages, never from a link: a cross-site navigation must not attach the signed-in user's account to an identity the navigator chose.

onStart: async (event: H3Event) => {
  const session = await useAuthSession(event);
  return session.id ? { memo: { userId: session.data.userId } } : undefined;
},
onSuccess: async (event, { identity, memo }) => {
  if (memo) return linkIdentity(memo.userId, identity);
  // a plain sign-in otherwise
},
<form method="post" action="/auth/github"><button>Link a GitHub account</button></form>

Sign in with AT Protocol

import { defineAtprotoHandler } from "unauth/n/providers/atproto";

app.all(
  "/auth/atproto",
  defineAtprotoHandler({
    clientId: "https://app.example/oauth-client-metadata.json",
    redirectUri: "https://app.example/auth/atproto",
    key,
    onStart: async (event: H3Event) => ({ loginHint: (await event.req.formData()).get("login") }),
    onSuccess,
  }),
);
  • onStart names the account (a handle, a DID or a server URL) as loginHint; the handler reads nothing from the request. Naming the account picks the server the sign-in is pushed to, so the start is a POST form from the application's own pages (ERR_AUTHN_CROSS_SITE_START otherwise).
  • The provider's client.metadata is the document to serve at clientId; define the provider once with defineAtprotoProvider and pass it as provider to share it with that route.
  • A clientId on http://localhost is the development client; its callback must be on a loopback IP. devOrigins: ["http://localhost:3000"] maps a localhost:3000 callback to 127.0.0.1:3000 and returns the result, or its failure, to http://localhost:3000, where onSuccess or onError runs.

Custom providers

A custom provider runs its own two legs over the engine's flow. state and the flow cookie only prove that the callback came back to the browser that started the sign-in, and that browser may be anyone's: complete must take the identity from a proof the provider issued and verifies there, never from what start was told. A magic link, with the proof a one-time token sent to the address:

import { UnauthError, defineOneTimeToken, defineProvider } from "unauth/n";

const magicLinks = defineOneTimeToken<{ email: string; state: string }>({
  key: signingKey,
  maxAge: "15m",
  purpose: "magic-link",
  consume: ({ jti, expiresAt }) => usedLinks.add(jti, expiresAt),
});

const magic = defineProvider<{ email: string }>({
  id: "magic",
  kind: "custom",
  start: async ({ record, redirectUri, loginHint }) => {
    if (!loginHint) {
      throw new UnauthError("Enter your email to sign in.", "ERR_AUTHN_LOGIN_HINT_REQUIRED", {
        status: 400,
      });
    }
    const link = new URL(redirectUri);
    link.searchParams.set("state", record.state);
    link.searchParams.set(
      "code",
      await magicLinks.issue({ email: loginHint, state: record.state }),
    );
    await sendMail(loginHint, link.href);
    return { url: new URL("/check-your-inbox", redirectUri) };
  },
  complete: async ({ params, record }) => {
    const claim = await magicLinks.open(params.code ?? "");
    if (claim.state !== record.state) {
      throw new UnauthError("This link belongs to another sign-in.", "ERR_AUTHN_STATE_MISMATCH", {
        status: 401,
        cause: { provider: "magic" },
      });
    }
    return {
      identity: {
        subject: claim.email,
        email: claim.email,
        emailVerified: true,
        profile: { email: claim.email },
      },
    };
  },
});
  • start sends the user somewhere and may seal data in the record for complete; it is the application's input, sealed, not a proof. Here the proof is the token: only the inbox receives it, and it names the state of the sign-in it was issued for.
  • complete receives the callback's params with state already checked against the record, and returns the identity it verified. A callback without a usable proof must throw: the engine never completes on its own.
  • consume records each token's jti until expiresAt and answers whether this was its first use; usedLinks is whatever store the deployment has.

Frameworks

The playgrounds/ run the same application on h3, Nitro 3 and Nuxt 4.

Development

local development
  • Clone this repository
  • Install latest LTS version of Node.js
  • Enable Corepack using corepack enable
  • Install dependencies using pnpm install
  • Run pnpm dev:prepare once, then pnpm dev (h3), pnpm dev:nitro or pnpm dev:nuxt

Why unauth?

I started by building unjwt, as I needed a cryptographically secure way to transmit sensitive information between various programming languages and servers. Not long after I started requiring some standardization, in particular on how to handle authentication flows, but as I was testing various libraries I've never been satisfied by their DX (either too basic or too complex to maintain). So I started building unauth as a collection of low-level primitives that get wrapped in high-level abstractions for every day use.

Credits

License

Published under the MIT license. Made by community 💛


🤖 auto updated with automd

About

A collection of low-level and high-level, server-agnostic, Authentication and Authorization utilities.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages