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:
- Persist the rotated token. Always through
setTokens(), never by writinglocalStoragedirectly. - 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 login — data.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).
