Skip to content
TALA
Esc
navigateopen⌘Jpreview
On this page

Authentication

Token lifetimes, the AES-256-GCM envelope over each JWT, mandatory refresh-token rotation and its theft response, and the OAuth code exchange.

TALA issues a short-lived access JWT and a longer-lived server-side refresh token. Both are wrapped in an AES-256-GCM envelope before they leave the server, so the value a client holds is not a readable JWT.

Lifetimes

Credential Lifetime Stored at rest as
Access token 15 minutes not stored
Refresh token 12 hours sha256 hash in refresh_tokens, keyed by jti
OAuth exchange code 60 seconds, single use sha256 hash in oauth_exchange_codes
Password reset token 30 minutes sha256 hash in password_reset
Email verification token 24 hours plain token in email_verification

Every one of those windows is enforced server-side. Expiry embedded in a JWT is not the only check.

The envelope

Tokens are signed as HS256 JWTs, then encrypted with AES-256-GCM using BUFFER_KEY (exactly 64 hex characters) before being returned. AuthGuard reverses that on every request: decrypt, then verify with algorithms: ['HS256'] pinned so a forged alg: none header cannot bypass verification.

The guard also rejects any account whose accountStatus is not VERIFIED, and strips password from req.user before handing it to a controller.

Login

POST /api/auth/login
Content-Type: application/json

{ "email": "you@example.com", "password": "…" }
{
  "message": "You are logged-in successfully",
  "data": {
    "user": { "id": "USa7Bk9x2Q", "email": "you@example.com" },
    "token": {
      "accessToken": "<encrypted access token>",
      "refreshToken": "<encrypted refresh token>"
    }
  }
}

A PENDING or SUSPENDED account is rejected here, not at first use.

Outcome Status Message
Wrong email or password 400 Invalid email and or password
Account is PENDING 400 Please verify your email before logging in
Account is SUSPENDED 400 Your account has been suspended

All three are 400 by design, not 401 — see Conventions. The distinct PENDING and SUSPENDED messages are only ever returned after a correct password, so they are not an account-enumeration oracle. Surface them to the user verbatim; the generic one is deliberately vague and should stay that way.

Making requests

GET /api/collections?scope=organization
Authorization: Bearer <encrypted_token>
x-organization-id: ORa7Bk9x2Q

x-organization-id is optional — see organization scoping.

Refresh

POST /api/auth/refresh-token
Content-Type: application/json

{ "refreshToken": "<encrypted_refresh_token>" }
{
  "data": {
    "token": "<new access token>",
    "refreshToken": "<rotated refresh token>"
  }
}

Note the missing message, and note that data.token is a string here — unlike login and OAuth exchange, where it is an object. GET /api/auth/user also omits message; the two are the only endpoints that do. See Conventions.

Rotation is mandatory

The presented refresh token is revoked and a new one issued. The old value is dead the instant the response is written.

An expired, unknown, or revoked refresh token returns 401 Access denied — the same status and message in all three cases, so the client cannot tell them apart and should not try. Treat any 401 from this endpoint as a dead session: clear both tokens and route to /login. That is what fetchJSON does.

Two client obligations follow:

  1. Persist the rotated token. Always through setTokens(), never by writing localStorage directly.
  2. Single-flight concurrent refreshes. Several requests can 401 at once; only one may call POST /api/auth/refresh-token. The others await the same promise.

The SPA does both:

let refreshInFlight: Promise<string | null> | null = null;

async function refreshAccessToken(): Promise<string | null> {
  const refreshToken = getRefreshToken();
  if (!refreshToken) return null;

  if (!refreshInFlight) {
    refreshInFlight = (async () => {
      /* … */
      setTokens(newAccessToken, rotatedRefreshToken);
      return newAccessToken;
    })();
  }

  return refreshInFlight;
}

Logout

POST /api/auth/logout
Content-Type: application/json

{ "refreshToken": "<encrypted_refresh_token>" }

Revokes the session server-side and returns 200 with { message: "You have been logged out" }. A bad or expired token is swallowed rather than rejected, so the client can treat logout as best-effort and never block the UI on the network:

export async function serverLogout(refreshToken: string | null): Promise<void> {
  if (!refreshToken) return;
  try {
    await fetch(`${API_BASE_URL}/api/auth/logout`, { /* … */ });
  } catch {
    // Swallow — the client-side clear below is what ends the UI session.
  }
}

OAuth

Google and GitHub both follow the same three-leg flow.

Start

The SPA sends the browser to the provider entry point. There is no XHR here — it is a full navigation.

onClick={() => { window.location.href = `${API_BASE_URL}/api/auth/google` }}

Callback

The provider returns to /api/auth/{google,github}/callback. The backend mints a single-use code, stores its sha256 hash with a 60-second expiry, and redirects to the SPA:

/loading?code=<single-use>&is_new=<bool>

No tokens travel in the URL. helmet’s no-referrer policy keeps even the code out of the Referer header.

Exchange

The SPA trades the code for real tokens over POST, stores them, and navigates.

const code = params.get("code");
// Strip the query either way so a reload cannot replay the code.
window.history.replaceState({}, document.title, window.location.pathname);
// …
const response = await fetchJSON<OAuthExchangeResponse>(
  `${API_BASE_URL}/api/auth/oauth/exchange`,
  { method: "POST", body: JSON.stringify({ code }) },
);
setTokens(response.data?.token?.accessToken, response.data?.token?.refreshToken);
await reload();
navigate("/home", { replace: true });

When the exchange fails

An expired code (older than 60 seconds), an already-used code, or a tampered code all return 401 Invalid or expired code — again indistinguishable, again deliberately. There is no retry: the code is consumed on first use. Recovery is to start the flow over by navigating to the provider entry point.

The response carries both tokens in the same shape as logindata.token.accessToken and data.token.refreshToken, nested, not flat. Store them through setTokens(), never by writing localStorage directly.

Account linking requires a verified email

Linking an OAuth login to an existing local account only happens when the provider asserts the email is verified. OAuthProfile carries that assertion explicitly.

Without the check, an attacker could register an unverified provider email colliding with a real user and seize the account.

Where tokens live on the client

localStorage, under tala_token and tala_refresh_token (plus tala_organization_id for the active org).

Was this page helpful?