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.
- 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
defineOAuth2ProvideranddefineOidcProvider - 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
startandcompletelegs - One-time tokens — signed, single-use, expiring tokens
- Web-standard — handlers take a request event
{ req, res: { headers } };startFlowandcompleteFlowtake aRequest - unjwt included —
unauth/jwk,unauth/jws,unauth/jwe,unauth/utils,unauth/sessionandunauth/dpopare unjwt's subpaths, one dependency to install
Built on:
npx nypm install unauthThe 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);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 thejweandonKeyLookupoptions followunauth/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 a421.redirectUrinames the callback outright instead. Withorigintoo, both are where the sign-in runs.onStart(event): optional; returns{ redirect, memo, loginHint, scopes, params }for this request.memois anything the application wants back at the callback, sealed;loginHintnames the account (OAuth'slogin_hint, an AT Protocol handle). A start that returns eithermemo, orloginHintfor a custom provider, is taken only as aPOSTfrom the application's own origin (ERR_AUTHN_CROSS_SITE_STARTotherwise): start those sign-ins from a form.onSuccess(event, result): required;resultholdsidentity,tokens,memoandredirect. Returning nothing answers a302toresult.redirect, else theredirectoption, else/; returning aResponseor a value answers that.fetch: optional; replaces the globalfetchfor every request a sign-in makes to a provider, where a deployment refuses private network ranges.onError(event, error): optional;erroris anUnauthErrorwithcode,causeandstatus, anUnauthErrorthrown byonStartoronSuccessincluded. Without it the failure is answered with a plainResponseof that status.- Annotate the
eventof the first hook you write with the framework's event type: every hook and the handler take it,resultandmemostay inferred. defineSignInHandler(provider, options)is the same handler for any provider. It answersGETandPOSTand refuses other methods with a405: mount it without a method filter (app.all(...), aroutes/auth/github.tsrather than.get.ts).
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>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,
}),
);onStartnames the account (a handle, a DID or a server URL) asloginHint; the handler reads nothing from the request. Naming the account picks the server the sign-in is pushed to, so the start is aPOSTform from the application's own pages (ERR_AUTHN_CROSS_SITE_STARTotherwise).- The provider's
client.metadatais the document to serve atclientId; define the provider once withdefineAtprotoProviderand pass it asproviderto share it with that route. - A
clientIdonhttp://localhostis the development client; its callback must be on a loopback IP.devOrigins: ["http://localhost:3000"]maps alocalhost:3000callback to127.0.0.1:3000and returns the result, or its failure, tohttp://localhost:3000, whereonSuccessoronErrorruns.
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 },
},
};
},
});startsends the user somewhere and may sealdatain the record forcomplete; it is the application's input, sealed, not a proof. Here the proof is the token: only the inbox receives it, and it names thestateof the sign-in it was issued for.completereceives the callback'sparamswithstatealready 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.consumerecords each token'sjtiuntilexpiresAtand answers whether this was its first use;usedLinksis whatever store the deployment has.
The playgrounds/ run the same application on h3, Nitro 3 and Nuxt 4.
local development
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.
- Thanks to Vidbase, Inc. (in particular to Van Nguyen) for the npm package name donation
Published under the MIT license. Made by community 💛
🤖 auto updated with automd