Skip to content

Latest commit

 

History

History
498 lines (385 loc) · 16 KB

tutorial.md

File metadata and controls

498 lines (385 loc) · 16 KB

Building custom authentication flows with Appwrite

While Appwrite provides built-in authentication methods like email/password, OAuth, and Magic URL, there are scenarios where you need more flexibility. You might have an existing user database, a legacy authentication system that needs to be maintained, or specific security requirements that demand custom implementation.

Appwrite's custom authentication solves these challenges by allowing you to integrate your existing authentication system or third-party identity providers. You can validate users through your system while still benefiting from Appwrite's session management and user features.

In this guide, you'll learn how to implement custom authentication flows using Appwrite's custom tokens. We'll cover validating users through an external system, generating custom tokens, creating secure sessions, and managing the complete authentication lifecycle.

What we'll build

A simple authentication demo that:

  • Uses a simulated third-party authentication system
  • Integrates with Appwrite's custom token authentication
  • Provides a login/logout flow with session management

Setting up your project

Before diving into the code, let's ensure you have the necessary prerequisites in place. Start by verifying your Node.js installation in your local environment:

node --version

Next, you'll need to set up your Appwrite project. Head over to the Appwrite Console and either create a new project or open an existing one. Make sure to note down your Project ID, which you can find in the Settings page.

To enable custom authentication, you'll need an API key with the appropriate permissions. In your project's overview page, navigate to the "Integrations" section and click on "API Keys". Create a new API key with a suitable name (e.g., "Custom Auth") and optionally set an expiry date. When configuring permissions, ensure you select the "Auth" scope. After creation, securely save your API key as you'll need it in the next steps.

Project setup

Let's start by creating a new Vite project. We'll use vanilla JavaScript for this tutorial to keep things simple and focused:

npm create vite@latest . -- --template vanilla

Our project will require several dependencies to handle both frontend and backend functionality. Install them using npm:

npm install appwrite cors dotenv express node-appwrite

Now, let's set up our environment configuration. Create a .env file in your project root to store your Appwrite credentials and other important variables:

VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your_project_id
VITE_BACKEND_URL=http://localhost:3000

# Server-only variables
APPWRITE_API_KEY=your_api_key
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=your_project_id

To properly configure our project for modern JavaScript modules and add convenient scripts, update your package.json:

{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "server": "node server.js",
    "build": "vite build",
    "preview": "vite preview"
  }
}

Backend implementation

The backend server will handle user validation and generate Appwrite custom tokens. Create a new file named server.js in your project root. Let's break down the implementation into logical sections.

First, we'll set up our basic server infrastructure by importing dependencies and loading environment variables:

import express from 'express'
import cors from 'cors'
import { Client, Users } from 'node-appwrite'
import dotenv from 'dotenv'

// Load environment variables
dotenv.config()

Before proceeding, we should validate that all required environment variables are present. This helps catch configuration issues early:

// Validate required environment variables
const requiredEnvVars = [
  'APPWRITE_ENDPOINT',
  'APPWRITE_PROJECT_ID',
  'APPWRITE_API_KEY',
]
for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    console.error(`Missing required environment variable: ${envVar}`)
    process.exit(1)
  }
}

With our environment validated, we can set up the Express server and configure necessary middleware:

const app = express()
app.use(cors())
app.use(express.json())

Now we'll initialize the Appwrite client with our configuration:

// Initialize Appwrite
const client = new Client()
  .setEndpoint(process.env.APPWRITE_ENDPOINT)
  .setProject(process.env.APPWRITE_PROJECT_ID)
  .setKey(process.env.APPWRITE_API_KEY)

const users = new Users(client)

For demonstration purposes, we'll create a simulated user database. In a real application, this would be replaced with your actual user database or authentication system:

// Simulate a third-party auth database
const thirdPartyUsers = {
  '[email protected]': {
    password: 'demo1234',
    name: 'Demo User',
    id: 'external_123',
  },
}

Note: This simulation uses plain text passwords for simplicity. In a production environment, always use secure password hashing and proper security measures.

Let's implement the login endpoint that will handle authentication requests:

// Simulate third-party login
app.post('/auth/external/login', async (req, res) => {
  const { email, password } = req.body

  // Simulate external auth validation
  const user = thirdPartyUsers[email]
  if (!user || user.password !== password) {
    return res.status(401).json({ message: 'Invalid credentials' })
  }

  try {
    // Check if user exists in Appwrite
    try {
      await users.get(user.id)
    } catch {
      // User doesn't exist, create them
      await users.create(
        user.id,
        email,
        undefined, // phone
        undefined, // password can be undefined for custom auth
        user.name,
      )
    }

    // After validating with external system, create Appwrite token
    const token = await users.createToken(user.id)
    res.json({
      userId: user.id,
      secret: token.secret,
      name: user.name,
    })
  } catch (error) {
    res.status(500).json({ message: error.message })
  }
})

This endpoint handles several important tasks:

  • Validates user credentials against our simulated database
  • Creates the user in Appwrite if they don't already exist
  • Generates a custom token for the authenticated user
  • Returns the token and user information to the client

Finally, let's start the server on our specified port:

const PORT = process.env.PORT || 3000

app.listen(PORT, () => {
  console.log(`Server running on <http://localhost>:${PORT}`)
})

With the server implementation complete, you can start it using:

npm run server

Frontend implementation

Now that our authentication server is running, let's create an intuitive user interface. We'll break this down into several parts: HTML structure, styling, and JavaScript logic. Each part plays a crucial role in creating a seamless authentication experience.

HTML Structure and Styling

The foundation of our frontend starts with a clean HTML structure. We'll create a simple container-based layout that will house our authentication components. In your index.html file, add the following code:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Appwrite Custom Auth Demo</title>
  </head>
  <body>
    <div class="container">
      <!-- Content will go here -->
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

To ensure our interface is easy to use, we'll add some CSS styles:

<style>
  .container {
    max-width: 400px;
    margin: 50px auto;
    padding: 20px;
    border: 1px solid #ccc;
    border-radius: 8px;
  }
  .form-group {
    margin-bottom: 15px;
  }
  input {
    width: 100%;
    padding: 8px;
    margin-top: 5px;
  }
  button {
    width: 100%;
    padding: 10px;
    background: #4caf50;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    margin-bottom: 10px;
  }
  .info {
    background: #f0f0f0;
    padding: 15px;
    border-radius: 4px;
    margin-bottom: 20px;
  }
  .hidden {
    display: none;
  }
</style>

To help users understand what the demo does, we'll add an informative section at the top which will include the test credentials:

<div class="info">
  <h3>Custom Token Auth Demo</h3>
  <p>
    This demonstrates using Appwrite's custom token authentication with a
    simulated third-party auth system.
  </p>
  <p>Try: [email protected] / demo1234</p>
</div>

The main authentication interface consists of two views: the login form and the logged-in state. First, let's create the login form with proper input validation:

<!-- External Login Form -->
<div id="loginForm">
  <h2>External Auth System</h2>
  <div class="form-group">
    <label for="email">Email:</label>
    <input type="email" id="email" required />
  </div>
  <div class="form-group">
    <label for="password">Password:</label>
    <input type="password" id="password" required />
  </div>
  <button id="loginButton">Login with External System</button>
</div>

We also need a view for when the user is successfully authenticated. This view will display the user's information and provide a logout option:

<!-- Logged In View -->
<div id="loggedInView" class="hidden">
  <h2>Welcome!</h2>
  <p id="userInfo"></p>
  <button id="logoutButton">Logout</button>
</div>

JavaScript implementation

Now let's implement the frontend logic in src/main.js. We'll break this down into specific parts that work together to handle the authentication flow.

First, we set up the connection to Appwrite. This code tells our frontend how to talk to Appwrite's servers:

import { Client, Account } from 'appwrite'

// Initialize Appwrite
const client = new Client()
  .setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
  .setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID)

const account = new Account(client)

We need quick access to our HTML elements to show/hide them and update their content. These variables help us do that:

// DOM Elements
const loginForm = document.getElementById('loginForm')
const loggedInView = document.getElementById('loggedInView')
const userInfo = document.getElementById('userInfo')

The handleExternalAuth function is the main piece of our login process. It takes the user's email and password and does four specific things:

// Handle external auth and Appwrite session creation
async function handleExternalAuth(email, password) {
  try {
    // First, authenticate with external system
    const response = await fetch(
      `${import.meta.env.VITE_BACKEND_URL}/auth/external/login`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password }),
      },
    )

    const data = await response.json()
    if (!response.ok) throw new Error(data.message)

    // Then create Appwrite session using the custom token
    const session = await account.createSession(data.userId, data.secret)

    // Show logged in state
    loginForm.classList.add('hidden')
    loggedInView.classList.remove('hidden')
    userInfo.textContent = `Logged in as: ${data.name}`

    return session
  } catch (error) {
    throw new Error('Authentication failed: ' + error.message)
  }
}

This function:

  1. Sends the login details to our backend
  2. Gets back a token if the login is successful
  3. Creates an Appwrite session with this token
  4. Shows the logged-in screen with the user's name

Next, we add click handlers to our login and logout buttons. These functions run when users click the buttons:

// Handle login button click
document.getElementById('loginButton').addEventListener('click', async () => {
  const email = document.getElementById('email').value
  const password = document.getElementById('password').value

  try {
    await handleExternalAuth(email, password)
  } catch (error) {
    alert(error.message)
  }
})

// Handle logout
document.getElementById('logoutButton').addEventListener('click', async () => {
  try {
    await account.deleteSession('current')
    loginForm.classList.remove('hidden')
    loggedInView.classList.add('hidden')
  } catch (error) {
    alert('Logout failed: ' + error.message)
  }
})

These click handlers:

  • Get the email and password from the form
  • Try to log the user in or out
  • Show error messages if something goes wrong
  • Switch between the login and logged-in screens

Lastly, we check if the user is already logged in when they load the page:

// Check auth status on load
async function checkAuth() {
  try {
    const session = await account.get()
    loginForm.classList.add('hidden')
    loggedInView.classList.remove('hidden')
    userInfo.textContent = `Logged in as: ${session.name}`
  } catch {
    loginForm.classList.remove('hidden')
    loggedInView.classList.add('hidden')
  }
}

checkAuth()

This check is important because it:

  • Looks for an existing login session
  • Shows the logged-in screen if a session exists
  • Shows the login form if no session is found

Running the application

To run the application, you'll need to start both the backend and frontend servers. First, start the backend server:

npm run server

Then, in a new terminal window, start the frontend development server:

npm run dev

Navigate to the URL shown by Vite (typically http://localhost:5173) in your browser. You can test the authentication using these credentials:

How it works

The authentication flow in this application follows a specific sequence:

When a user submits the login form, the frontend sends their credentials to our backend server. The server then validates these credentials against its user database. Upon successful validation, the backend uses Appwrite's SDK to create a custom token for the user.

This token, along with user information, is returned to the frontend. The frontend then uses this token to create an Appwrite session, effectively authenticating the user and allowing them to access protected resources.

Next steps

To enhance this basic implementation, consider these improvements:

  • Replace the simulated user database with your actual authentication system
  • Add comprehensive error handling and input validation
  • Implement user registration functionality
  • Improve the UI with loading states and better error messages. You might want to use a UI framework or template engine depending on your project's requirements.

Troubleshooting

Here are some common issues you might encounter and their solutions:

Backend issues

If you're experiencing environment variable issues, ensure your .env file is in the project root and contains all required variables. For port conflicts, either stop the process using port 3000 or change the port using the PORT environment variable. When encountering Appwrite API errors, verify your API key has the necessary permissions in the Appwrite console.

Frontend issues

For CORS errors, check that your backend's CORS configuration matches your frontend's domain. If sessions aren't persisting, verify that cookies are enabled in your browser. For authentication issues, ensure you're using the correct credentials ([email protected] / demo1234) or check your user database configuration.

Conclusion

This tutorial has demonstrated how to implement custom authentication with Appwrite. You've learned how to:

  • Set up a custom authentication server integrated with Appwrite
  • Generate and manage Appwrite custom tokens
  • Create a frontend that handles the authentication flow
  • Implement secure session management

While we've used a simple in-memory user database for demonstration, these concepts apply to any external authentication provider, whether it's your own user database or third-party identity providers.

Remember that authentication is an important security component. Before deploying to production, ensure you've implemented proper security measures, error handling, and user management features.