Authentication

Sign-up and email verification

Hosted and server-side sign-up, and the emailed verification code.

Hosted sign-up#

Link to /api/auth/signup (or use SignUpButton). The hosted page takes an email and password and emails a six-digit code. Once the code is confirmed, the page returns to your redirect URI. When sign-up is turned off for the environment, the hosted page says so and the API refuses it (403).

Server-side sign-up#

handleAuth also accepts POST /api/auth/sign-up with { email, password, firstName?, lastName? }, and AuthClient.signUp does the same from any server. Both create the user and send the code, and return an authorization_session_id. The code is then confirmed on the hosted verification page.

lib/auth-client.tstype-checked

// Server-side calls with AuthClient, for flows you drive yourself.
import { AuthClient } from '@3een/auth';
import type { AuthError } from '@3een/auth';

const apiUrl = process.env.AUTH_API_URL!;
const apiKey = process.env.AUTH_API_KEY!; // secret: server only

const client = new AuthClient({
  clientId: process.env.AUTH_CLIENT_ID!,
  apiKey,
  apiUrl,
  redirectUri: 'https://app.example.com/dashboard',
});

/** The environment your API key belongs to (AuthClient methods need its id). */
export async function environmentId(): Promise<string> {
  const res = await fetch(`${apiUrl}/app/auth/validate-key`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!res.ok) throw new Error('The API key was rejected');
  const body = (await res.json()) as { environmentId: string };
  return body.environmentId;
}

/** AuthClient throws an Error carrying the API's status and code. */
function describe(error: unknown): string {
  const e = error as Partial<AuthError> & Error;
  if (e.statusCode === 401) return 'Invalid email or password';
  if (e.statusCode === 429) return 'Too many attempts. Try again in a minute.';
  return e.message || 'Something went wrong';
}

export async function signUp(email: string, password: string) {
  try {
    // Sends a verification code by email; the user finishes on the hosted
    // verification page with the returned authorization_session_id.
    const { authorization_session_id } = await client.signUp({ email, password, environmentId: await environmentId() });
    return { ok: true as const, authorizationSessionId: authorization_session_id };
  } catch (error) {
    return { ok: false as const, message: describe(error) };
  }
}

export async function signIn(email: string, password: string) {
  try {
    const { accessToken, refreshToken, expiresIn, user } = await client.signIn({
      email,
      password,
      environmentId: await environmentId(),
    });
    // Store the tokens in httpOnly cookies; never hand them to browser JavaScript.
    return { ok: true as const, accessToken, refreshToken, expiresIn, user };
  } catch (error) {
    return { ok: false as const, message: describe(error) };
  }
}

export async function refresh(refreshToken: string) {
  const { accessToken, refreshToken: next, expiresIn } = await client.refreshToken(refreshToken);
  return { accessToken, refreshToken: next ?? refreshToken, expiresIn };
}

export async function forgotPassword(email: string) {
  // Always the same answer, whether or not the address has an account.
  // The email links to your hosted reset-password page.
  await client.forgotPassword(email, await environmentId());
}

export async function signOut(accessToken: string) {
  await client.signOut(accessToken); // revokes the session server-side
}

Email verification#

  • The code is six digits and expires after 10 minutes.
  • At most three codes can be sent to an address in 10 minutes; a new code resets the countdown.
  • A code is accepted only for the sign-up it was issued for: its authorization session, its environment and its kind of account.

Password policy#

8 to 128 characters, not the account's own email, and not found in known breaches. The breach check sends only the first five characters of the password's SHA-1 hash (k-anonymity), and is skipped, not failed, if the breach service is unreachable. There are no composition rules.