Skip to content
SaaS4Builders
Authentication

Login, Logout & Token Refresh

Email/password login, OAuth popup flow, password reset, token rotation, logout, and frontend session management.

This page covers how returning users authenticate, how tokens are rotated securely, and how the frontend manages sessions across page loads.


Email/Password Login

POST /api/v1/auth/login

Auth: None (public) Throttle: 5 requests per minute

Request

FieldTypeRequiredDescription
emailstringYesAccount email address
passwordstringYesAccount password
rememberbooleanNoExtend session duration (cookie mode)
revoke_previousbooleanNoRevoke all existing tokens before issuing new ones

What Happens

The LoginUser action validates credentials using Laravel's Auth::attempt():

backend/app/Application/Auth/Actions/LoginUser.php
final class LoginUser
{
    public function execute(LoginUserData $data): array
    {
        if (! Auth::attempt(
            ['email' => $data->email, 'password' => $data->password],
            $data->remember
        )) {
            throw ValidationException::withMessages([
                'email' => [__('auth.failed')],
            ]);
        }

        $user = User::where('email', $data->email)->firstOrFail();

        return ['user' => $user->load('tenants')];
    }
}

After successful authentication, the controller generates a token pair via RefreshAccessToken::createTokenPair().

Response (200)

{
  "user": {
    "id": 42,
    "name": "Jane Smith",
    "email": "jane@example.com",
    "is_platform_admin": false,
    "tenants": [
      {
        "id": "9f8a7b6c-...",
        "name": "Acme Inc.",
        "slug": "acme-inc"
      }
    ]
  },
  "accessToken": "1|abc123...",
  "refreshToken": "def456..."
}

Error Response (401)

{
  "message": "These credentials do not match our records.",
  "code": "INVALID_CREDENTIALS"
}
When revoke_previous is true, all existing access and refresh tokens for the user are revoked before the new pair is issued. Use this when you want to enforce single-session behavior.

OAuth Login

SaaS4Builders supports OAuth authentication via Google and GitHub using Laravel Socialite. The flow uses a popup window to avoid full-page redirects.

The Popup Flow

Frontend (parent)         Frontend (popup)       Backend                  OAuth Provider
─────────────────         ────────────────       ───────                  ──────────────
1. GET /oauth/providers
   ← ["google","github"]

2. window.open(popup)
                          GET /oauth/google/
                          redirect
                          ← 302 ──────────────────────────────────────── Google consent
                                                                         ← callback URL
                                                 GET /oauth/google/
                                                 callback
                                                 ├── getUser(google)
                                                 ├── AuthenticateViaOAuth
                                                 │   ├── Find by oauth_id
                                                 │   ├── OR link by email
                                                 │   └── OR create new user
                                                 ├── Cache code (60s TTL)
                                                 └── 302 → /auth/callback
                                                           ?code=UUID

                          /auth/callback page
                          postMessage({
                            type: 'oauth-callback',
                            code: UUID
                          })
                          window.close()

3. Receives postMessage
   POST /oauth/exchange
   { code: UUID }
                                                 Pull from cache
                                                 Cookie mode → session
                                                 Token mode → token pair
   ← { user, isNewUser,
       accessToken?,
       refreshToken? }

Provider Listing

GET /api/v1/auth/oauth/providers

Returns the list of enabled OAuth providers:

{
  "providers": ["google", "github"]
}

Providers are enabled by setting the appropriate credentials in your environment (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET).

User Resolution

When the OAuth callback arrives, the AuthenticateViaOAuth action resolves the user through three branches:

backend/app/Application/Auth/Actions/AuthenticateViaOAuth.php
final class AuthenticateViaOAuth
{
    public function execute(OAuthUserData $data): array
    {
        return DB::transaction(function () use ($data): array {
            // 1. Try to find existing user by OAuth provider + ID
            $user = User::where('oauth_provider', $data->provider)
                ->where('oauth_provider_id', $data->oauthId)
                ->first();

            $isNewUser = false;

            if (! $user) {
                // 2. Check if user exists with same email — link OAuth to existing account
                $user = User::where('email', $data->email)->first();

                if ($user) {
                    $user->update([
                        'oauth_provider' => $data->provider,
                        'oauth_provider_id' => $data->oauthId,
                        'avatar' => $data->avatar,
                    ]);
                } else {
                    // 3. Create new user + tenant
                    $isNewUser = true;
                    $user = User::create([
                        'name' => $data->name,
                        'email' => $data->email,
                        'oauth_provider' => $data->provider,
                        'oauth_provider_id' => $data->oauthId,
                        'avatar' => $data->avatar,
                        'email_verified_at' => now(), // OAuth emails are pre-verified
                    ]);

                    // Create first tenant
                    $tenant = $this->createTenant->execute(
                        new CreateTenantData(name: $user->name."'s Organization"),
                        $user,
                    );
                }
            }

            return ['user' => $user->load('tenants'), 'isNewUser' => $isNewUser];
        });
    }
}

Code Exchange

The callback stores a short-lived code in the cache (60-second TTL) and redirects to the frontend. The frontend popup sends the code to the parent window via postMessage, which then exchanges it:

POST /api/v1/auth/oauth/exchange
FieldTypeRequiredDescription
codestringYesThe UUID code from the OAuth callback
backend/app/Http/Controllers/Api/V1/Auth/OAuthController.php
public function exchange(
    ExchangeOAuthCodeRequest $request,
    RefreshAccessToken $refreshAction,
): JsonResponse {
    $dto = $request->toDto();

    $cached = Cache::pull("oauth_exchange:{$dto->code}");

    if ($cached === null) {
        return response()->json([
            'message' => __('auth.oauth_code_expired'),
            'code' => 'OAUTH_CODE_EXPIRED',
        ], 400);
    }

    $user = User::findOrFail($cached['user_id']);

    $isStateful = EnsureFrontendRequestsAreStateful::fromFrontend($request);

    if ($isStateful) {
        Auth::guard('web')->login($user);
        $request->session()->regenerate();
    } else {
        $tokens = $refreshAction->createTokenPair($user);
        $response['accessToken'] = $tokens['accessToken'];
        $response['refreshToken'] = $tokens['refreshToken'];
    }

    return response()->json($response);
}
The OAuth exchange code expires after 60 seconds. If the user takes too long to complete the popup flow, the exchange will fail with OAUTH_CODE_EXPIRED. The frontend should handle this by prompting the user to try again.

Password Reset

Password reset follows a two-step flow: request a link, then apply the new password.

POST /api/v1/auth/forgot-password

Throttle: 5 requests per minute

FieldTypeRequired
emailstringYes

Returns { "message": "We have emailed your password reset link." } (200) regardless of whether the email exists — this prevents email enumeration.

Step 2: Reset Password

POST /api/v1/auth/reset-password

Throttle: 5 requests per minute

FieldTypeRequired
emailstringYes
tokenstringYes
passwordstringYes
password_confirmationstringYes

Returns { "message": "Your password has been reset." } (200) on success, or { "message": "..." } (400) if the token is invalid or expired.

Both endpoints delegate to Laravel's built-in Password facade via dedicated Actions (SendPasswordResetLink, ResetPassword).


Token Refresh

POST /api/v1/auth/refresh

Auth: None (uses the refresh token itself for authentication) Throttle: 10 requests per minute

Request

FieldTypeRequiredDescription
refresh_tokenstringYesThe 64-character refresh token

Token Rotation

The RefreshAccessToken action implements secure token rotation:

backend/app/Application/Auth/Actions/RefreshAccessToken.php
public function execute(string $refreshTokenString): array
{
    $hashedToken = hash('sha256', $refreshTokenString);
    $refreshToken = RefreshToken::where('token', $hashedToken)->first();

    if (! $refreshToken) {
        throw ValidationException::withMessages([
            'refresh_token' => [__('auth.invalid_refresh_token')],
        ]);
    }

    // Token reuse detection — potential theft
    if ($refreshToken->isRevoked()) {
        $refreshToken->revokeFamily();  // Revoke ALL tokens in this family
        throw ValidationException::withMessages([
            'refresh_token' => [__('auth.refresh_token_reused')],
        ]);
    }

    if ($refreshToken->isExpired()) {
        throw ValidationException::withMessages([
            'refresh_token' => [__('auth.refresh_token_expired')],
        ]);
    }

    return DB::transaction(function () use ($refreshToken): array {
        $user = $refreshToken->user;

        // Revoke current token (rotation)
        $refreshToken->revoke();

        // Create new token in the same family
        $newRefreshTokenString = Str::random(64);
        RefreshToken::create([
            'user_id' => $user->id,
            'token' => hash('sha256', $newRefreshTokenString),
            'family' => $refreshToken->family,
            'expires_at' => now()->addDays(self::REFRESH_TOKEN_EXPIRATION_DAYS),
        ]);

        $accessToken = $user->createToken('auth-token')->plainTextToken;

        return [
            'user' => $user->load('tenants'),
            'accessToken' => $accessToken,
            'refreshToken' => $newRefreshTokenString,
        ];
    });
}

How It Works

  1. The incoming token is hashed and looked up in the refresh_tokens table.
  2. If the token is revoked: the entire token family is revoked (theft detection), and a 401 is returned. This forces both the legitimate user and any attacker to re-authenticate.
  3. If the token is expired: a 401 is returned.
  4. If the token is valid: it is revoked, and a new token is created in the same family. A new access token is also generated.

Response (200)

{
  "user": { "id": 42, "name": "Jane Smith", "email": "jane@example.com" },
  "accessToken": "2|xyz789...",
  "refreshToken": "ghi012..."
}

Error Responses

CodeMeaning
INVALID_REFRESH_TOKENToken not found or was revoked (family revoked on reuse)

The refresh_tokens table stores: user_id, token (SHA-256 hashed), family (groups related tokens), expires_at, and revoked_at.


Logout

POST /api/v1/auth/logout

Auth: auth:sanctum + tenant.resolve

Logout revokes all tokens and ends the session:

backend/app/Http/Controllers/Api/V1/Auth/AuthController.php
public function logout(Request $request, LogoutUser $action): JsonResponse
{
    $user = $request->user();

    if ($user) {
        $action->execute($user); // Deletes access token + revokes refresh tokens
    }

    // Clear the guard's cached user BEFORE invalidating the session.
    // Without this, AuthenticateSession middleware stores the current
    // user's password hash in the new session, causing a 401 on the
    // next login by a different user.
    Auth::guard('web')->logout();

    if ($request->hasSession()) {
        $request->session()->invalidate();
        $request->session()->regenerateToken();
    }

    return response()->json(['message' => __('auth.logout_success')]);
}

What happens on logout:

  1. LogoutUser action — Deletes the current Sanctum access token and revokes all refresh tokens for the user.
  2. Guard logout — Clears the guard's cached user to prevent password hash mismatch on the next login.
  3. Session cleanup — Invalidates the session and regenerates the CSRF token (cookie mode only).

Frontend Logout

The auth store's logout() method handles the client side:

  1. Calls POST /api/v1/auth/logout.
  2. Calls /api/studio/logout to clear any Nuxt Studio session.
  3. Clears all local state: user, tokens, session hints, related stores.
  4. Navigates to /login.

Frontend Session Management

Auth Store Initialization

The auth store uses an idempotent initialize() method that runs once per page load:

Token mode:

  1. Load tokens from localStorage.
  2. If no token exists, mark as initialized (guest user) — skip the /auth/me call entirely.
  3. If a token exists, call bootstrap() which hits GET /auth/me.

Cookie mode:

  1. Check for a sessionActive hint in localStorage.
  2. If no hint exists, mark as initialized (guest user) — skip the /auth/me call.
  3. If the hint exists, call bootstrap() which hits GET /auth/me.

The session hint is a simple boolean flag set on login and cleared on logout. It prevents unnecessary 401 responses on guest pages.

Bootstrap: The /auth/me Call

GET /api/v1/auth/me is the single source of truth for frontend auth state. It returns:

{
  "user": {
    "id": 42,
    "name": "Jane Smith",
    "email": "jane@example.com",
    "is_platform_admin": false,
    "avatar": "https://...",
    "roles": ["owner"],
    "permissions": ["team.invite", "team.manage", "billing.manage"],
    "tenants": [
      {
        "id": "9f8a7b6c-...",
        "name": "Acme Inc.",
        "user_role": "owner",
        "has_active_subscription": true,
        "has_billing_details": true,
        "onboarding_completed_at": "2025-06-15T10:30:00+00:00"
      }
    ]
  },
  "currentTenant": {
    "id": "9f8a7b6c-...",
    "name": "Acme Inc."
  }
}

When an admin is impersonating a user, the response includes an additional impersonation field (see Impersonation for details).

Login Flow (Frontend)

The auth store's login() method handles both auth modes:

  1. Cookie mode: Fetch the CSRF cookie first (GET /sanctum/csrf-cookie), then POST /auth/login with credentials: 'include'.
  2. Token mode: POST /auth/login without cookies. Save the returned accessToken and refreshToken to localStorage.
  3. After login: Set the session hint, call bootstrap() via /auth/me to load the full profile (roles, permissions, tenant context).
  4. Redirect: Platform managers go to /manager, regular users go to /dashboard.

useSession Composable

The useSession composable provides session lifecycle helpers:

frontend/features/foundation/auth/composables/useSession.ts
const { checkAuth, requireAuth, requireGuest, startPeriodicRefresh, stopPeriodicRefresh } = useSession()

// In a page or middleware
await checkAuth()         // Initialize auth if not done yet
await requireAuth()       // Redirect to /login if not authenticated
await requireGuest()      // Redirect to /dashboard if already authenticated

// Optional: refresh user profile periodically
startPeriodicRefresh(300_000) // Every 5 minutes

Auth Middleware

The auth.ts middleware runs on every navigation to protected pages:

  1. Skips during SSR (server-side rendering).
  2. Calls authStore.initialize() if not already initialized.
  3. If the user is not authenticated, redirects to /login with a redirect query parameter so the user returns to their intended page after login.

What's Next