Skip to content
This page View Markdown Open in ChatGPT Open in Claude

Authentication

The visitors of a website sign in with the auth providers of the account : the token generators of the Auth Providers page (a table of users, or a custom one), and the tokens of Google, Azure AD and AWS Cognito the APIs take. Nothing new to set up : a website names the providers it takes, the server checks tokens as the APIs check them.

site.config.ts
export default defineSite({
    auth: {
        // The auth providers the visitors sign in with.
        providers: ['customers'],
        // Where a visitor who is not signed in goes for a page which asks for one : /login?next=/account
        loginPath: '/login',
        // How long a session lasts.
        sessionDays: 30,
    },
});

Sign in

pages/login.tsx
import { HttpError, redirect } from 'am-ssr';
import type { PageConfig, PageContext, PageProps } from 'am-ssr';

export const config: PageConfig = { title: 'Sign in', index: false };

/** A path of the website only : `?next=//evil.example` goes home. */
function nextOf(ctx: PageContext) {
    const next = String(ctx.form.next ?? ctx.query.next ?? '/');
    return next.startsWith('/') && !next.startsWith('//') ? next : '/';
}

export function loader(ctx: PageContext) {
    if (ctx.user) redirect(nextOf(ctx));
    return { next: nextOf(ctx) };
}

export async function action(ctx: PageContext) {
    try {
        await ctx.auth.login({ provider: 'customers', username: String(ctx.form.email ?? ''), password: String(ctx.form.password ?? '') });
    } catch (error) {
        if (error instanceof HttpError && error.status < 500) {
            ctx.status(401);
            return { error: 'The email or the password is wrong.' };
        }
        throw error;
    }
    redirect(nextOf(ctx), 303);
}

export default function Login({ data, actionData }: PageProps<typeof loader, { error: string }>) {
    return (
        <form method="post">
            {actionData?.error && <p class="error">{actionData.error}</p>}
            <input type="hidden" name="next" value={data?.next ?? '/'} />
            <input type="email" name="email" autocomplete="username" required />
            <input type="password" name="password" autocomplete="current-password" required />
            <button type="submit">Sign in</button>
        </form>
    );
}

ctx.auth.login({ provider, username, password, ... }) asks the provider for a token (as the token API of the account does), checks it, and keeps it in the session of the visitor :

  • a cookie of the website, sealed with AES-256-GCM (a key of the installation and of the website) : nothing in it can be read or changed by the browser ;
  • HttpOnly, SameSite=Lax, Secure over HTTPS, for the path of the website ;
  • it ends after sessionDays, and a token which stands for nobody any more (expired, user removed) leaves it at the next request.

A wrong name or password is a 401 : say the same words for both, so the page does not tell which emails exist.

Sign up

A visitor who signs up adds a row to the table of users of the provider. The website may create such a row and never change nor remove one, and the new user gets its groups from the rule, whatever the page sent :

site.config.ts
data: {
    write: [{ table: 'mongodb/shop/customers', allow: ['create'], set: { groups: ['customer'] } }],
},
await ctx.db.saveSingleOrMultiple({ instance: 'mongodb', database: 'shop', table: 'customers', saveData: { fullName, email, password } });
await ctx.auth.login({ provider: 'customers', username: email, password });

A column of the schema with hashing stores the password hashed ; a unique index on email refuses a second account of the same address.

Sign out

A sign-out is a POST (a link of another site can not sign someone out) :

pages/logout.ts
import { redirect } from 'am-ssr';
import type { PageContext } from 'am-ssr';

export async function POST(ctx: PageContext) {
    await ctx.auth.logout();
    redirect('/', 303);
}
<form method="post" action="/logout"><button type="submit">Sign out</button></form>

Tokens from elsewhere

A visitor who signed in with Google (Google Identity Services), Azure AD (MSAL) or AWS Cognito in the browser hands its token to the website, which checks it as the APIs check it :

const user = await ctx.auth.setToken('google', String(ctx.form.credential));

The kinds are db, custom, google, azure and aws ; the provider of the account which takes such tokens is listed in auth.providers.

The visitor

ctx.user The visitor who signed in, or null : the row of the user for a table of users (without its password), what the token says for the others, with id, name, email, groups, provider, kind and claims beside.
ctx.auth.isLoggedIn Whether a visitor signed in.
ctx.auth.groups, ctx.auth.hasGroup('admin', 'staff') The groups of the user : the column of groups of the provider, or what its token says.

Who may open a page

access says who may open a page, read from its code when it is saved : the server refuses a visitor before any code of the page runs.

export const config: PageConfig = { access: 'authenticated' };                 // a signed-in visitor
export const config: PageConfig = { access: { groups: ['admin', 'staff'] } };  // in one of these groups
export const config: PageConfig = { access: { providers: ['staff'] } };        // signed in with one of these
export const config: PageConfig = { access: 'public' };                        // everyone
export const config: PageConfig = {
    // Code which decides, run before the loaders.
    access: { authenticated: true, check: async (ctx) => (await ctx.db.count({ … })) > 0 },
};
  • access of a layout applies to every page inside it : pages/admin/_layout.tsx with { groups: ['admin'] } closes the whole folder.
  • A page which says nothing gets defaultAccess of site.config.ts ('public' unless it says otherwise). defaultAccess: 'authenticated' closes the whole website but the pages which say 'public' (the sign-in page).
  • A visitor who is not signed in goes to auth.loginPath?next=<the page> for a page ; an endpoint answers 401. A signed-in visitor who may not gets the page of the error 403.
  • Endpoints say it the same way.

The rows of each visitor

access says who opens a page ; the row rules of site.config.ts say which rows each visitor reaches, whatever a page asks :

data: {
    read: [{ table: 'mongodb/shop/orders', where: { customer_id: '$user._id' }, requireUser: true }],
},

ctx.db.query({ ..., table: 'orders', find: {} }) gives the orders of the visitor and no one else's ; getById of the order of another visitor finds nothing. See data.