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.
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):
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" },
});
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 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.
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:
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:
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):
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();
},
];
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.