Skip to Content
ContributeFrontendFrontend Authentication

Authentication

This document explains the authentication system used in the Rhesis frontend application.

Authentication Architecture

The Rhesis frontend uses NextAuth.js (src/auth.ts) to manage authentication sessions. The backend provides a native authentication system with support for:

  • Email/password login and registration
  • OAuth login (Google, GitHub) — the OAuth handshake is owned entirely by the backend
  • Magic link (passwordless) login
  • Automatic access token refresh via refresh token rotation

All sensitive tokens (access token, refresh token) are stored inside NextAuth’s httpOnly, encrypted session cookie and are never exposed to client-side JavaScript or passed through a NextAuth client call directly.

Authentication Flow

Every login path — email/password, OAuth, and magic link — ends the same way: the backend hands the frontend a short-lived, single-use auth code (60-second TTL), which the frontend exchanges server-side for the real tokens. Raw tokens never appear in a signIn() call or a redirect URL.

Email/Password Login

  1. User submits credentials via the AuthForm component
  2. Frontend calls POST /auth/login/email, receiving an auth_code
  3. Frontend calls signIn('credentials', { code: auth_code, redirect: false })
  4. NextAuth’s authorize() callback exchanges the code server-side via POST /auth/exchange-code, gets back { session_token, refresh_token }, and decodes the JWT locally to build the session
  5. NextAuth stores both tokens in its httpOnly session cookie

OAuth Login (Google, GitHub)

  1. User clicks “Sign in with Google” or “Sign in with GitHub”
  2. Frontend redirects to GET /auth/login/{provider}
  3. Backend redirects to the OAuth provider, then handles the callback at GET /auth/callback
  4. Backend wraps the tokens in the same short-lived auth code and redirects to the frontend
  5. Frontend calls signIn('credentials', { code: auth_code }), which exchanges the code exactly as in the email/password flow
  1. User enters their email address
  2. Frontend calls POST /auth/magic-link
  3. Backend sends a single-use login link via email
  4. User clicks the link; frontend calls POST /auth/magic-link/verify, receiving an auth_code
  5. Frontend calls signIn('credentials', { code: auth_code })

Session Management

Credentials Provider

NextAuth’s CredentialsProvider takes only an opaque code, never raw tokens:

src/auth.ts
CredentialsProvider({
credentials: { code: { type: 'text' } },
async authorize(credentials) {
    const response = await fetch(`${BACKEND_URL}/auth/exchange-code`, {
      method: 'POST',
      body: JSON.stringify({ code: credentials?.code }),
    });
    const { session_token, refresh_token } = await response.json();
    const claims = decodeJwtUser(session_token); // decoded locally, no extra network call
    return { id: claims.id, session_token, refresh_token, ...claims };
},
})
src/auth.ts
cookies: {
sessionToken: {
    name: 'next-auth.session-token',
    options: {
      httpOnly: true,      // Cannot be read by JavaScript (XSS protection)
      sameSite: 'lax',
      path: '/',
      secure: shouldUseSecureCookies(), // true when FRONTEND_URL starts with https://
      maxAge: SESSION_DURATION_SECONDS, // 7 days
    },
},
},

Automatic Token Refresh

  1. On every request, the token is refreshed if it’s within 60 seconds of expiry
  2. If so, POST /auth/refresh is called with the stored refresh token
  3. The new access token and rotated refresh token replace the old values in the cookie
  4. This is transparent to the user — access tokens last 15 minutes, sessions up to 7 days

If the refresh fails, token.error = 'RefreshTokenError' is set on the JWT and propagated to the session object; src/proxy.ts reacts to it by clearing the session and redirecting home with force_logout=true.

Route Protection

Next.js 16 renamed the edge middleware convention from middleware.ts to src/proxy.ts:

  • Public paths: driven by isPublicPath() in src/constants/paths.ts (/, /auth/*, static assets)
  • Protected routes: everything under (protected)/ requires a session
  • Onboarding redirect: if the decoded token has no organization, the user is redirected to /onboarding

proxy.ts decodes the session JWT locally rather than calling the backend on every request — it only makes a network call (POST /auth/refresh) when the token is close to expiry. POST /auth/verify is a real backend endpoint, but it isn’t called on this request path.

Authentication Components

AuthForm

components/auth/AuthForm.tsx handles both login and registration:

  • Email/password form with validation
  • OAuth provider buttons (Google, GitHub) — shown only when the provider is enabled, per GET /auth/providers (fetched via the same-origin /api/auth-config route on mount, not literally at app startup)
  • Magic link option
  • Toggle between login and registration modes

Session Establishment Pattern

Every place that establishes a session calls signIn('credentials', { code }) with the auth code, never with raw tokens:

example
const result = await signIn('credentials', {
code: authCodeFromBackend,
redirect: false,
});

Files that use this pattern:

  • components/auth/AuthForm.tsx — Email/password login and registration
  • app/auth/signin/page.tsx — OAuth callback (exchanges auth code for a session)
  • app/auth/magic-link/page.tsx — Magic link verification
  • app/auth/verify-email/page.tsx — Email verification

Environment Variables

.env.local
# Required
NEXTAUTH_SECRET=your-nextauth-secret
FRONTEND_URL=http://localhost:3000
API_BASE_URL=http://localhost:8080
BACKEND_URL=http://localhost:8080

Google/GitHub OAuth client credentials (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GH_CLIENT_ID, GH_CLIENT_SECRET) are backend environment variables — the frontend never talks to the OAuth providers itself, it only redirects the browser to the backend’s GET /auth/login/{provider}, which owns the whole handshake.

Security Considerations

  1. httpOnly cookies: tokens are never exposed to client-side JavaScript
  2. Auth-code exchange: OAuth/email/magic-link flows hand off a short-lived (60s), single-use code instead of raw tokens, so tokens never appear in a redirect URL or client-side signIn() call
  3. Short-lived access tokens: access tokens expire in 15 minutes, limiting the impact of XSS
  4. HTTPS: always use HTTPS in production to protect cookies in transit
  5. Token refresh: automatic refresh (within 60s of expiry) maintains sessions without long-lived tokens
  6. CORS: backend CORS configuration restricts which origins can make API requests