Sign users in
Sign-in with any OpenID Connect or OAuth 2.0 provider, with sessions on the server and a guard on routes.
@shadow-js/dusk/auth is a small set of building blocks for the authorization code flow with PKCE. It has no runtime dependency (the ID token is verified with WebCrypto) and an app that does not import it pays nothing. It works with Auth0, Okta, Keycloak, Google, Microsoft Entra ID, any OpenID Connect issuer, and plain OAuth 2.0 providers such as GitHub and Discord. Clerk and Convex have their own packages.
Register https://app.example/auth/callback as a redirect URI at the provider, and put the secrets in the environment, checked when the app starts:
import { defineEnv } from "@shadow-js/dusk/server";
export const env = defineEnv({
SESSION_SECRET: { type: "string", minLength: 32, secret: true },
OIDC_CLIENT_ID: { type: "string" },
OIDC_CLIENT_SECRET: { type: "string", secret: true },
APP_URL: { type: "string" },
});
The client is one object. Spread the provider's preset first, then your registration. Sessions keep the sign-in state on the server, and a rate limiter protects the routes that call the provider.
import { createOidcClient, google } from "@shadow-js/dusk/auth";
import { createRateLimiter, createSessionManager } from "@shadow-js/dusk/server";
import { env } from "./env";
const config = env.parse(process.env).all;
/** Sessions keep the sign-in state and the user on the server; the cookie holds an opaque, signed id. */
export const sessions = createSessionManager({ secrets: [config.SESSION_SECRET] });
/** The provider. Spread the preset first, then your registration. */
export const oidc = createOidcClient({
...google(),
clientId: config.OIDC_CLIENT_ID,
clientSecret: config.OIDC_CLIENT_SECRET,
redirectUri: `${config.APP_URL}/auth/callback`,
sessions,
});
/** Each sign-in costs the server calls to the provider: 10 at once, then one every 5 seconds, per client address. */
export const signInLimiter = createRateLimiter({
capacity: 10,
refillPerSecond: 0.2,
name: "sign-in",
});
The session middleware saves the session and sets its cookie, so it goes in the handler options:
import type { HandlerOptions } from "@shadow-js/dusk/server";
import { sessions } from "./auth";
const options: Omit<HandlerOptions, "manifest"> = {
security: { allowedHosts: ["app.example"] },
middleware: [sessions.middleware()],
};
export default options;
The login route sends the visitor to the provider. returnTo is where they land afterward; anything that is not a path of your site is replaced by /.
import type { RequestEvent } from "@shadow-js/dusk/server";
import { oidc, signInLimiter } from "../../auth";
export const middleware = [signInLimiter.middleware()];
export const GET = (event: RequestEvent): Promise<Response> =>
oidc.authorize(event, { returnTo: event.url.searchParams.get("returnTo") ?? "/" });
The callback validates everything, signs the visitor in with a new session id and redirects to returnTo. A refused sign-in is an OidcError with a code; a server fault reaches the error handler.
import { isOidcError } from "@shadow-js/dusk/auth";
import type { RequestEvent } from "@shadow-js/dusk/server";
import { oidc, signInLimiter } from "../../auth";
export const middleware = [signInLimiter.middleware()];
export const GET = async (event: RequestEvent): Promise<Response> => {
try {
return await oidc.handleCallback(event);
} catch (error) {
if (isOidcError(error) && error.status < 500) {
return new Response(null, {
status: 303,
headers: { location: `/auth/failed?code=${error.code}`, "cache-control": "no-store" },
});
}
throw error;
}
};
Logout is a POST, so the CSRF check refuses a cross-site request that would sign the user out. It revokes the refresh token, ends the session and uses the provider's logout.
import type { RequestEvent } from "@shadow-js/dusk/server";
import { oidc } from "../../auth";
export const POST = (event: RequestEvent): Promise<Response> =>
oidc.signOut(event, { postLogoutRedirectUri: "/" });
requireUser is a guard. On an API route or a server function it answers 401 for a visitor who is not signed in; on a page, put it in the folder's middleware with a loginPath.
import { requireUser } from "@shadow-js/dusk/auth";
import { apiHandler, json } from "@shadow-js/dusk/server";
import { oidc } from "../../auth";
export const GET = apiHandler({ guard: requireUser(oidc) }, async ({ event }) =>
json({ user: await oidc.user(event) }),
);
Next: images and islands. Provider presets, refresh tokens, userinfo and verifying your own JWTs are in Sign-in with OAuth and OpenID Connect, and Auth.js and better-auth shows the same guard on those libraries.