Password-less Authentication (With code) in TypeScript

hiro - Apr 1 - - Dev Community

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 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 user uses it, the user might not be able to login to your app because the user accessed the link two times (1st attempt with the preview mode, and 2nd time with your app).

  2. In-app browser: In some cases a link gets opened within an in-app browser, depending on users' preference. 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 - One user enter an email address in the login screen in your app, it will email a short-lived authentication code (say, 4-6 digit code) to the user. Then the user enter the code in the login screen. 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 (my go to language)
  • Hono.js for a lightweight server framework
  • Class-based structure to enhance code organization

Sample Code Here

Repository Layer

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");
    }
  }

Enter fullscreen mode Exit fullscreen mode

Service Layer

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;
}

Enter fullscreen mode Exit fullscreen mode

Handler Layer

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
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

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":"..."}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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. I personally like it:

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);
}
// ...

Enter fullscreen mode Exit fullscreen mode

But the truth is, we just put off try-catch statement somewhere in our service layer and repository layer, which resulted in a lot more code.

Also, as TypeScript doesn't have variable shadowing, we need to give each returned value a unique name (see above, Or we could use let keyword for variable declaration).

Thanks for reading ✌️

. . . . . . . . . . .
Terabox Video Player