Authentication with MonkeysLegion 2.0 + Next.js / React

php dev.to

A complete guide to implementing JWT-based registration, login, session persistence, and token refresh using the MonkeysLegion v2 API with a Next.js (or React) frontend.


Table of Contents

  1. API Endpoints Overview
  2. Backend Setup (MonkeysLegion)
  3. Frontend: API Client
  4. Frontend: Auth Context Provider
  5. Frontend: Register Page
  6. Frontend: Login Page
  7. Frontend: Protected Routes
  8. Token Refresh Flow
  9. Common Issues & Solutions

API Endpoints Overview

MonkeysLegion v2 exposes the following auth endpoints under the /api/v2/auth prefix:

Method Endpoint Auth Description
POST /auth/register No Create a new user + company
POST /auth/login No Authenticate and get JWT tokens
POST /auth/refresh No Exchange refresh token for new access token
GET /auth/me Yes Get authenticated user profile
POST /auth/logout Yes Invalidate tokens
POST /auth/forgot-password No Request password reset

Response Shapes

Login ResponsePOST /auth/login

{"data":{"access_token":"eyJ0eXAiOiJKV1Q...","refresh_token":"eyJ0eXAiOiJKV1Q...","token_type":"Bearer","expires_in":1800,"user":{"id":1,"email":"user@example.com","full_name":"John Doe"}}}
Enter fullscreen mode Exit fullscreen mode

Register ResponsePOST /auth/register (HTTP 201)

{"data":{"message":"Registration successful","user":{"id":1,"email":"user@example.com","full_name":"John Doe"},"company":{"hash":"abc123","name":"Acme Inc."},"access_token":"eyJ0eXAiOiJKV1Q...","refresh_token":"eyJ0eXAiOiJKV1Q...","token_type":"Bearer","expires_in":1800}}
Enter fullscreen mode Exit fullscreen mode

Me ResponseGET /auth/me

{"data":{"id":1,"email":"user@example.com","full_name":"John Doe","phone":null,"timezone":"UTC","status":"active","verified":false,"two_factor":false,"created_at":"2026-05-08T01:25:23+00:00"}}
Enter fullscreen mode Exit fullscreen mode

Backend Setup (MonkeysLegion)

1. Install the Framework

composer require monkeyscloud/monkeyslegion:^2.0.8
Enter fullscreen mode Exit fullscreen mode

Important: Version 2.0.8+ is required. Earlier versions have a bug in DatabaseUserProvider where typed properties (DateTimeImmutable, int, bool) throw TypeError during hydration from raw PDO strings.

2. Entity — app/Entity/User.php

<?php
declare(strict_types=1);

namespace App\Entity;

use MonkeysLegion\Auth\Contract\AuthenticatableInterface;
use MonkeysLegion\Query\Attribute\{Entity, Field, Id, Timestamps, Hidden, Fillable};

#[Entity(table: 'users')]
#[Timestamps]
class User implements AuthenticatableInterface
{
    #[Id]
    public private(set) int $id;

    #[Field(type: 'string', length: 255, unique: true)]
    #[Fillable]
    public string $email;

    #[Field(type: 'string', length: 255)]
    #[Fillable]
    public string $full_name;

    #[Field(type: 'string', length: 255)]
    #[Hidden]
    public string $password_hash;

    #[Field(type: 'integer', default: 1)]
    public int $token_version = 1;

    #[Field(type: 'datetime')]
    public private(set) \DateTimeImmutable $created_at;

    #[Field(type: 'datetime')]
    public private(set) \DateTimeImmutable $updated_at;

    // ── AuthenticatableInterface ──
    public function getAuthIdentifier(): int { return $this->id; }
    public function getAuthPassword(): string { return $this->password_hash; }
}
Enter fullscreen mode Exit fullscreen mode

3. Controller — app/Controller/Api/AuthController.php

<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Service\AuthService;
use MonkeysLegion\Http\Attribute\{Route, Prefix, Throttle, Authenticated};
use MonkeysLegion\Http\Message\Response;
use Psr\Http\Message\ServerRequestInterface;

#[Prefix('/api/v2/auth')]
class AuthController
{
    public function __construct(
        private readonly AuthService $auth,
    ) {}

    #[Route('POST', '/login', name: 'auth.login')]
    #[Throttle(max: 5, per: 60)]
    public function login(ServerRequestInterface $request): Response
    {
        $body = json_decode((string) $request->getBody(), true) ?? [];

        if (empty($body['email']) || empty($body['password'])) {
            return Response::json(['error' => 'Missing credentials'], 400);
        }

        $result = $this->auth->login($body['email'], $body['password']);

        if ($result === null) {
            return Response::json(['error' => 'Invalid credentials'], 401);
        }

        return Response::json([
            'data' => [
                'access_token'  => $result['access_token'],
                'refresh_token' => $result['refresh_token'],
                'token_type'    => 'Bearer',
                'expires_in'    => $result['expires_in'],
                'user' => [
                    'id'        => $result['user']->id,
                    'email'     => $result['user']->email,
                    'full_name' => $result['user']->full_name,
                ],
            ],
        ]);
    }

    #[Route('POST', '/register', name: 'auth.register')]
    #[Throttle(max: 3, per: 60)]
    public function register(ServerRequestInterface $request): Response
    {
        $body = json_decode((string) $request->getBody(), true) ?? [];

        if (empty($body['email']) || empty($body['password']) || empty($body['name'])) {
            return Response::json(['error' => 'Missing required fields'], 400);
        }

        // Check for duplicate email
        $existing = $this->auth->findByEmail($body['email']);
        if ($existing !== null) {
            return Response::json([
                'error'   => 'Validation failed',
                'details' => ['email' => 'Email already registered'],
            ], 422);
        }

        $result = $this->auth->register($body);

        return Response::json([
            'data' => [
                'message'       => 'Registration successful',
                'user'          => [
                    'id'        => $result['user']->id,
                    'email'     => $result['user']->email,
                    'full_name' => $result['user']->full_name,
                ],
                'access_token'  => $result['access_token'],
                'refresh_token' => $result['refresh_token'],
                'token_type'    => 'Bearer',
                'expires_in'    => $result['expires_in'],
            ],
        ], 201);
    }

    #[Route('GET', '/me', name: 'auth.me')]
    #[Authenticated]
    public function me(ServerRequestInterface $request): Response
    {
        // NOTE: The attribute is 'auth.user', NOT 'user'
        $user = $request->getAttribute('auth.user');

        return Response::json([
            'data' => [
                'id'         => $user->id,
                'email'      => $user->email,
                'full_name'  => $user->full_name,
                'status'     => $user->status,
                'created_at' => $user->created_at->format('c'),
            ],
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Tip: Always use $request->getAttribute('auth.user') — the AuthenticationMiddleware sets the attribute as auth.user, not user.


Frontend: API Client

Create a reusable API client that handles JWT tokens, automatic refresh, and typed requests.

src/lib/api.ts

"use client";

const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8088";

interface ApiOptions {
  method?: string;
  body?: unknown;
  headers?: Record<string, string>;
  token?: string | null;
}

class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  private getToken(): string | null {
    if (typeof window === "undefined") return null;
    return localStorage.getItem("mm_token");
  }

  async request<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
    const { method = "GET", body, headers = {}, token } = options;
    const authToken = token ?? this.getToken();

    const res = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        "Content-Type": "application/json",
        ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
        ...headers,
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    // Auto-refresh on 401
    if (res.status === 401) {
      const refreshed = await this.refreshToken();
      if (refreshed) {
        return this.request<T>(path, { ...options, token: this.getToken() });
      }
      this.clearTokens();
      throw new Error("Unauthorized");
    }

    if (!res.ok) {
      const err = await res.json().catch(() => ({ error: res.statusText }));
      throw new Error(err.error || err.message || "Request failed");
    }

    return res.json();
  }

  private async refreshToken(): Promise<boolean> {
    const refresh = typeof window !== "undefined"
      ? localStorage.getItem("mm_refresh")
      : null;
    if (!refresh) return false;

    try {
      const res = await fetch(`${this.baseUrl}/api/v2/auth/refresh`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ refresh_token: refresh }),
      });

      if (!res.ok) return false;

      const data = await res.json();
      if (data.data?.access_token) {
        localStorage.setItem("mm_token", data.data.access_token);
        if (data.data.refresh_token) {
          localStorage.setItem("mm_refresh", data.data.refresh_token);
        }
        return true;
      }
      return false;
    } catch {
      return false;
    }
  }

  private clearTokens() {
    if (typeof window !== "undefined") {
      localStorage.removeItem("mm_token");
      localStorage.removeItem("mm_refresh");
      window.location.href = "/login";
    }
  }

  // ── Auth Methods ──────────────────────────────────────────
  login(email: string, password: string) {
    return this.request<{
      data: {
        access_token: string;
        refresh_token: string;
        user: { id: number; email: string; full_name: string };
      };
    }>("/api/v2/auth/login", { method: "POST", body: { email, password } });
  }

  register(name: string, email: string, password: string, companyName: string) {
    return this.request<{
      data: {
        access_token: string;
        refresh_token: string;
        user: { id: number; email: string; full_name: string };
      };
    }>("/api/v2/auth/register", {
      method: "POST",
      body: { name, email, password, company_name: companyName },
    });
  }
}

export const api = new ApiClient(API_URL);
Enter fullscreen mode Exit fullscreen mode

Frontend: Auth Context Provider

Wrap your app with AuthProvider to share auth state across all components.

src/lib/auth.tsx

"use client";

import React, {
  createContext, useContext, useEffect, useState, useCallback,
  type ReactNode,
} from "react";
import { api } from "./api";

interface User {
  id: number;
  name: string;
  email: string;
}

interface AuthState {
  user: User | null;
  loading: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (
    name: string, email: string, password: string, companyName: string
  ) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  // On mount: validate stored token by calling /me
  useEffect(() => {
    const token = localStorage.getItem("mm_token");
    if (token) {
      api
        .request<{ data: { id: number; email: string; full_name: string } }>(
          "/api/v2/auth/me"
        )
        .then((res) =>
          setUser({
            id: res.data.id,
            email: res.data.email,
            name: res.data.full_name,
          })
        )
        .catch(() => {
          localStorage.removeItem("mm_token");
          localStorage.removeItem("mm_refresh");
        })
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);

  const login = useCallback(async (email: string, password: string) => {
    const res = await api.login(email, password);
    localStorage.setItem("mm_token", res.data.access_token);
    localStorage.setItem("mm_refresh", res.data.refresh_token);
    // Map full_name → name for frontend consistency
    const u = res.data.user;
    setUser({ id: u.id, email: u.email, name: u.full_name });
  }, []);

  const register = useCallback(
    async (
      name: string, email: string, password: string, companyName: string
    ) => {
      const res = await api.register(name, email, password, companyName);
      localStorage.setItem("mm_token", res.data.access_token);
      localStorage.setItem("mm_refresh", res.data.refresh_token);
      const u = res.data.user;
      setUser({ id: u.id, email: u.email, name: u.full_name });
    },
    []
  );

  const logout = useCallback(() => {
    localStorage.removeItem("mm_token");
    localStorage.removeItem("mm_refresh");
    setUser(null);
    window.location.href = "/login";
  }, []);

  return (
    <AuthContext.Provider value={{ user, loading, login, register, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used within AuthProvider");
  return ctx;
}
Enter fullscreen mode Exit fullscreen mode

Note: The API returns full_name but we map it to name in the frontend User interface for simplicity. This mapping happens in three places: login, register, and the /me response handler.

Wire Into Layout — src/app/layout.tsx

import { AuthProvider } from "@/lib/auth";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Frontend: Register Page

src/app/(auth)/register/page.tsx

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/lib/auth";

export default function RegisterPage() {
  const { register } = useAuth();
  const router = useRouter();
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [companyName, setCompanyName] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setLoading(true);

    try {
      await register(name, email, password, companyName);
      router.push("/login"); // Redirect to login after registration
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : "Registration failed");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <h1>Create Account</h1>

      {error && <div className="error">{error}</div>}

      <input type="text"     value={name}        onChange={(e) => setName(e.target.value)}        placeholder="Full name"    required />
      <input type="email"    value={email}       onChange={(e) => setEmail(e.target.value)}       placeholder="Email"        required />
      <input type="password" value={password}    onChange={(e) => setPassword(e.target.value)}    placeholder="Password"     required />
      <input type="text"     value={companyName} onChange={(e) => setCompanyName(e.target.value)} placeholder="Company name" required />

      <button type="submit" disabled={loading}>
        {loading ? "Creating..." : "Create Account"}
      </button>

      <p>Already have an account? <Link href="/login">Sign in</Link></p>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Frontend: Login Page

src/app/(auth)/login/page.tsx

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/lib/auth";

export default function LoginPage() {
  const { login } = useAuth();
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setLoading(true);

    try {
      await login(email, password);
      router.push("/"); // Redirect to dashboard
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : "Login failed");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <h1>Welcome Back</h1>

      {error && <div className="error">{error}</div>}

      <input type="email"    value={email}    onChange={(e) => setEmail(e.target.value)}    placeholder="Email"    required />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required />

      <button type="submit" disabled={loading}>
        {loading ? "Signing in..." : "Sign in"}
      </button>

      <p>Don't have an account? <Link href="/register">Create one</Link></p>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Frontend: Protected Routes

Wrap dashboard pages with a layout that redirects unauthenticated users.

src/app/(dashboard)/layout.tsx

"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  const { user, loading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!loading && !user) {
      router.push("/login");
    }
  }, [user, loading, router]);

  if (loading) {
    return <div>Loading...</div>;
  }

  if (!user) return null;

  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

Using the User in Components

"use client";

import { useAuth } from "@/lib/auth";

export default function ProfileCard() {
  const { user, logout } = useAuth();

  return (
    <div>
      <h2>Welcome, {user?.name}</h2>
      <p>{user?.email}</p>
      <button onClick={logout}>Sign Out</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Token Refresh Flow

The API client handles token refresh automatically. Here's the sequence:

  1. Browser makes a request (e.g. GET /api/v2/messages)
  2. API Client attaches the stored Bearer token
  3. If the API returns 401 Unauthorized (token expired):
    • Client sends POST /api/v2/auth/refresh with the refresh token
    • On success: stores the new tokens and retries the original request
    • On failure: clears all tokens and redirects to /login

Token Lifetimes

Token Lifetime Storage Key
Access Token 30 minutes localStorage("mm_token")
Refresh Token 7 days localStorage("mm_refresh")

Warning: If both tokens are expired (user inactive for 7+ days), the client clears storage and redirects to /login.


Common Issues & Solutions

1. TypeError: Cannot assign string to property ... of type DateTimeImmutable

Cause: DatabaseUserProvider in MonkeysLegion ≤ 2.0.7 does raw assignment from PDO strings to typed properties.

Fix: Upgrade to monkeyscloud/monkeyslegion:^2.0.8 which includes castValue() type coercion in the hydrator.


2. /me returns null user — $request->getAttribute('user') is null

Cause: The AuthenticationMiddleware sets the attribute as auth.user, not user.

Fix:

- $user = $request->getAttribute('user');
+ $user = $request->getAttribute('auth.user');
Enter fullscreen mode Exit fullscreen mode

3. 422 Unprocessable Content on register

Cause: The email is already registered. The API returns:

{"error":"Validation failed","details":{"email":"Email already registered"}}
Enter fullscreen mode Exit fullscreen mode

Fix: Use a different email, or parse the error details in the frontend to show a specific message.


4. full_name vs name mismatch

Cause: The API returns full_name but your frontend User interface expects name.

Fix: Map the field in every place you read user data:

setUser({ id: u.id, email: u.email, name: u.full_name });
Enter fullscreen mode Exit fullscreen mode

5. CORS errors from localhost:3000localhost:8088

Cause: The API needs CORS headers for cross-origin requests.

Fix: Add CORS middleware in your MonkeysLegion middleware stack:

// config/middleware.mlc
MonkeysLegion\Http\Middleware\CorsMiddleware::class
Enter fullscreen mode Exit fullscreen mode

Or set the environment variable:

CORS_ORIGINS=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

6. Page redirects to /login on refresh despite being logged in

Cause: The /me endpoint crashes (often the DateTimeImmutable bug), so the auth provider clears tokens and sets user = null.

Fix: Upgrade MonkeysLegion to 2.0.8+ and verify /me works:

curl -s http://localhost:8088/api/v2/auth/me \
  -H "Authorization: Bearer YOUR_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Quick Reference

# Register a user via curl
curl -X POST http://localhost:8088/api/v2/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "securepass123",
    "company_name": "Acme Inc."
  }'

# Login
curl -X POST http://localhost:8088/api/v2/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "john@example.com", "password": "securepass123"}'

# Get current user
curl http://localhost:8088/api/v2/auth/me \
  -H "Authorization: Bearer <access_token>"

# Refresh token
curl -X POST http://localhost:8088/api/v2/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "<refresh_token>"}'
Enter fullscreen mode Exit fullscreen mode

MonkeysLegion

Source: dev.to

arrow_back Back to Tutorials