Password-less Authentication (With code) in TypeScript

Password-less Authentication (With code) in TypeScript


TypeScript

Implementing password-less authentication can transform your application’s user experience. But, like any technology shift, it comes with nuances to navigate. I recently integrated password-less login for our product at work using Firebase’s magic links and encountered some common pitfalls. In this article, I’ll delve into these challenges and share the solutions and insights I gained along the way.

Firebase already have a nice feature (aka magic links), but as far as I searched the internet there are some pitfalls:

  1. Preview mode (iOS) iOS has a handy preview feature for a mobile app in it. When keep pressing a link for a second within your mail app on iOS, you can open the link using a small preview window. The downside of it is if a user utilizes the feature he/she can’t login to your app (because the link needs to be opened in a different browser app).

  2. In-app browser In some cases a link gets opened within an in-app browser. And if your app is a web app, the consequence is that the user logs in to your app within the in-app browser (not a default browser app, which is not what you want).

  3. App review Since it’s impossible for reviewers to provide their email address for the app review, you need to somehow provide them an alternative to login to your app for the review.

There is another option - implement a login method that sends a short-lived authentication code to user via email from scratch. It doesn’t solve the issue #3 mentioned above. But it still seems worth trying.

So let’s explore implementing a short-lived authentication code sent via email. Here’s how I approached it:

  • Firebase for authentication
  • TypeScript for familiarity
  • Hono.js for a lightweight server framework
  • Class-based structure to enhance code organization

Sample Code Here

Database Repository

I like to only expose database APIs that are necessary to create my app. Repository pattern help us achieve this:

// repository.ts
import { Firestore } from "firebase-admin/firestore";
import { AuthCodeDoc, AuthCodeDocSchema } from "./domain";
import { Err, Ok, Result } from "../types";

const AUTH_CODE_COLLECTION = "auth_code";

export class FirestoreAuthCodeRepository {
  private readonly db: Firestore;
  constructor(db: Firestore) {
    this.db = db;
  }

  public async get(uid: string): Promise<Result<AuthCodeDoc>> {
    const snapshot = await this.db
      .collection(AUTH_CODE_COLLECTION)
      .doc(uid)
      .get();
    if (!snapshot.exists) {
      return Err("data not found in db");
    }
    const parsedData = AuthCodeDocSchema.safeParse(snapshot.data());
    if (!parsedData.success) {
      await this.delete(uid);
      return Err("failed to parse data in db");
    }
    const authCodeDoc = parsedData.data;
    return Ok(authCodeDoc);
  }

  public async create(uid: string, newDoc: any): Promise<Result<any>> {
    try {
      await this.db.collection(AUTH_CODE_COLLECTION).doc(uid).set(newDoc);
      return Ok(newDoc);
    } catch (err) {
      console.log(err);
      return Err("failed to create doc");
    }
  }

  public async update(
    uid: string,
    newDoc: Partial<AuthCodeDoc>
  ): Promise<Result<void>> {
    try {
      await this.db.collection(AUTH_CODE_COLLECTION).doc(uid).update(newDoc);
      return Ok(undefined);
    } catch (err) {
      console.log(err);
      return Err("failed to update doc");
    }
  }

  public async delete(uid: string): Promise<Result<void>> {
    try {
      await this.db.collection(AUTH_CODE_COLLECTION).doc(uid).delete();
      return Ok(undefined);
    } catch (e) {
      console.log(e);
      return Err("failed to delete doc");
    }
  }

And then, all the business logics should be placed, not in HTTP handler layer, but service layer. Here’s an example of the service layer:

// service.ts
import { AuthCodeDoc } from "./domain";
import { Err, Ok, Result } from "../types";

type UserRecord = { uid: string };

interface DBRepository {
  get(uid: string): Promise<Result<AuthCodeDoc>>;
  create(uid: string, doc: any): Promise<Result<AuthCodeDoc>>;
  update(uid: string, doc: Partial<AuthCodeDoc>): Promise<Result<void>>;
  delete(uid: string): Promise<Result<void>>;
}

interface AuthRepository {
  getUserByEmail(email: string): Promise<UserRecord>;
  createCustomToken(uid: string): Promise<string>;
}

const MAX_ATTEMPTS = 3;
const EXPIRATION_SEC = 60 * 1_000;

export class AuthCodeService {
  constructor(private db: DBRepository, private auth: AuthRepository) {}

  public async validate(
    email: string,
    code: string
  ): Promise<Result<{ customToken: string }>> {
    // get uid
    let userRecord: UserRecord;
    try {
      userRecord = await this.auth.getUserByEmail(email);
    } catch (err) {
      console.log({ err });
      return Err("user not found");
    }
    // get authentication code data from db
    const doc = await this.db.get(userRecord.uid);
    if (!doc.success) {
      return Err(doc.detail);
    }
    const authCodeDoc = doc.data;
    if (authCodeDoc.expiresAt._seconds < Date.now() / 1000) {
      await this.db.delete(userRecord.uid);
      return Err("code expired");
    }
    if (authCodeDoc.code !== code) {
      await this.db.update(userRecord.uid, {
        email: authCodeDoc.email,
        code: authCodeDoc.code,
        attempts: authCodeDoc.attempts + 1,
      });
      if (authCodeDoc.attempts + 1 >= MAX_ATTEMPTS) {
        await this.db.delete(userRecord.uid);
      }
      return Err("invalid code");
    }
    const customToken = await this.auth.createCustomToken(userRecord.uid);
    await this.db.delete(userRecord.uid);
    return Ok({ customToken });
  }

  public async generateCode(email: string): Promise<Result<{ code: string }>> {
    let userRecord: UserRecord;
    try {
      userRecord = await this.auth.getUserByEmail(email);
    } catch (err) {
      console.log({ err });
      return Err("user not found");
    }
    // generate authentication code
    const code = getRandomCode(4);
    const now = Date.now();
    // store code to database
    const result = await this.db.create(userRecord.uid, {
      email,
      code,
      attempts: 0,
      expiresAt: new Date(now + EXPIRATION_SEC),
      createdAt: new Date(now),
    });
    if (!result.success) {
      return Err(result.detail);
    }
    return Ok({ code });
  }
}

export class EmailService {
  constructor() {}

  public async sendEmailWithAuthCode(
    to: string,
    code: string
  ): Promise<Result<{ success: boolean }>> {
    try {
      console.log(`sending email to ${to} with authentication code: ${code}`);
      return Ok({ success: true });
    } catch (e) {
      console.log(e);
      return Err("failed to send email to user");
    }
  }
}

function getRandomCode(length: number) {
  let code = "";
  for (let i = 0; i < length; i++) {
    code += Math.floor(Math.random() * 10);
  }
  return code;
}

Finally, combine all of them into HTTP handler layer:

// handler.ts
import { Context } from "hono";

import { Result } from "../types";

interface AuthCodeService {
  validate(
    email: string,
    code: string
  ): Promise<Result<{ customToken: string }>>;
  generateCode(email: string): Promise<Result<{ code: string }>>;
}

interface EmailService {
  sendEmailWithAuthCode(
    to: string,
    code: string
  ): Promise<Result<{ success: boolean }>>;
}

export class AuthHandler {
  constructor(
    private authService: AuthCodeService,
    private emailService: EmailService
  ) {}

  public async handleAuth(c: Context) {
    const authType = c.req.query("auth_type");
    switch (authType) {
      case "code":
        return this.handleAuthCodeRequest(c);
      default:
        return c.json({ success: false, detail: "bad request" }, 400);
    }
  }

  public async handleLogin(c: Context) {
    const body = await c.req.json();
    // TODO: validation
    const result = await this.authService.validate(body.email, body.code);
    if (!result.success) {
      return c.json({ success: false, detail: result.detail }, 400);
    }
    return c.json({ success: true, customToken: result.data.customToken }, 200);
  }

  public async handleAuthCodeRequest(c: Context) {
    const body = await c.req.json();
    const codeResult = await this.authService.generateCode(body.email);
    if (!codeResult.success) {
      return c.json({ success: false, detail: codeResult.detail }, 400);
    }
    const emailResult = await this.emailService.sendEmailWithAuthCode(
      body.email,
      codeResult.data.code
    );
    if (!emailResult.success) {
      return c.json({ success: false, detail: emailResult.detail }, 400);
    }
    return c.json(
      {
        success: true,
        detail: "authentication code generated!",
      },
      201
    );
  }
}

Let’s Try It!

# create a test user
npm run seed "<enter email address here>"  # ➜ user <email address> created!

# run dev server
npm run dev  # ➜ http://localhost:3000

# open another terminal, and generate authentication code
curl -X POST "http://localhost:3000/auth?auth_type=code" -d '{"email": "user@example.com"}' -H "Content-Type: application/json"
# ➜ {"success":true,"detail":"authentication code generated!"}
# ➜ see the server side terminal to get the authentication code

# get authentication code via email and then, login using the code
curl -X POST "http://localhost:3000/login" -d '{"email": "user@example.com", "code": "1234"}' -H "Content-Type: application/json"
# ➜ {"success":true,"customToken":"..."}

Another Try - Result Type

Inspired by Rust, I implemented a custom Result type in TypeScript to streamline error handling. I don’t think throwing error in general purpose language like TypeScript is a bad language design. But in Rust, I found handling and refactoring logics around Result and Option types are just fun. And I like to bring the joy to TypeScript. Here is my code:

// types.ts
export type Ok<T> = {
  success: true;
  data: T;
};

export function Ok<T>(data: T): Ok<T> {
  return {
    success: true,
    data,
  };
}

export type Err = {
  success: false;
  detail: string;
};

export function Err(detail: string): Err {
  return {
    success: false,
    detail,
  };
}

export type Result<T> = Ok<T> | Err;

The result is, since TypeScript doesn’t have syntaxes that Rust has (say, ? shorthand), we always need to write if statement to handle the returned value just like Go. The good thing is we can avoid try-catch statement so that it’s easy to read now:

const codeResult = await this.authService.generateCode(body.email);
if (!codeResult.success) {
  return c.json({ success: false, detail: codeResult.detail }, 400);
}
const emailResult = await this.emailService.sendEmailWithAuthCode(
  body.email,
  codeResult.data.code
);
if (!emailResult.success) {
  return c.json({ success: false, detail: emailResult.detail }, 500);
}
// ...

(I love Go by the way)

Also, as TypeScript doesn’t have variable shadowing, we need to give each returned value a unique name (see above, Or we can use let if you like).

Thanks for reading ✌️

© 2024 Hiro