[]previous and next page

A complete app

The app below signs users in with Google and has a login route, a callback route, a protected page and API route, and logout.

For agentsUpdated

The app below signs users in with Google and has a login route, a callback route, a protected page and API route, and logout. To use another provider, change the spread on the first line of createOidcClient (see Providers).

Register https://app.example/auth/callback as a redirect URI at the provider and put the three secrets in the environment (see Environment variables):

src/env.tsts
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" },
});
src/auth.tsts
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:

src/entry-server.tsts
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 /.

src/routes/auth/login.tsts
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 route finishes the sign-in. handleCallback 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; the example sends the visitor to a page that explains it and lets a server fault (a status of 500 or more) reach the error handler.

src/routes/auth/callback.tsts
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. signOut revokes the refresh token at the provider, destroys the session and redirects to the provider's logout (RP-initiated logout), which returns the visitor to postLogoutRedirectUri:

src/routes/auth/logout.tsts
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 Dusk guard. On an API route or a server function it answers 401 for a visitor who is not signed in:

src/routes/api/me.tsts
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) }),
);

For pages, run the same guard in the folder's middleware. With loginPath a page request is redirected to your login route with returnTo set to the page the visitor asked for (GET and HEAD only; other methods get the 401):

src/routes/account/_middleware.tsts
import { requireUser } from "@shadow-js/dusk/auth";
import type { Middleware } from "@shadow-js/dusk/server";
import { oidc } from "../../auth";

const guard = requireUser(oidc, { loginPath: "/auth/login" });

export const middleware: Middleware[] = [
  async (event, next) => {
    await guard(event);
    return next();
  },
];
src/routes/account/index.tsxtsx
import type { JSX } from "@shadow-js/web";

export default function Account(): JSX.Element {
  return <p>You are signed in.</p>;
}

signInLimiter is shared by the two routes, so a client that starts sign-ins in a loop cannot also exhaust the callback. It takes nothing from requests the rest of the app serves.