Infra: Auth

Overview

Two authentication methods. Both produce the same token pair. The token pair is: a short-lived JWT access token + a long-lived opaque refresh token stored in Redis.


Method 1: Google OAuth 2.0 (PKCE)

Flow

1. Frontend calls GET /api/auth/google/url
   → core-api generates: state (random UUID), code_verifier (PKCE), code_challenge
   → stores in Redis: SET oauth:state:{state} {code_verifier} EX 600 (10 min)
   → returns { url: "https://accounts.google.com/o/oauth2/v2/auth?..." }
 
2. Browser redirects to Google
 
3. Google redirects to GET /api/auth/google/callback?code=...&state=...
   → core-api validates state from Redis (replay protection)
   → exchanges code for Google tokens using stored code_verifier
   → fetches user profile from Google (email, name, avatar)
   → runs domain rule check on email (see below)
   → finds or creates user in DB
   → issues tokens
   → redirects to frontend: {FRONTEND_URL}/auth/callback?access_token=...
     (or sets httpOnly cookie for refresh token)

Google OAuth params

const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
googleAuthUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
googleAuthUrl.searchParams.set('redirect_uri', `${API_URL}/api/auth/google/callback`);
googleAuthUrl.searchParams.set('response_type', 'code');
googleAuthUrl.searchParams.set('scope', 'openid email profile');
googleAuthUrl.searchParams.set('state', state);
googleAuthUrl.searchParams.set('code_challenge', codeChallenge);
googleAuthUrl.searchParams.set('code_challenge_method', 'S256');
googleAuthUrl.searchParams.set('hd', tenant.domainRules.allowedDomains[0]); // hint Google to show only org accounts

Method 2: Email OTP

Flow

1. POST /api/auth/otp/request  { email: "student_ug25@ashoka.edu.in" }
   → run domain rule check first (fail fast — don't send OTP to unknown emails)
   → generate 6-digit numeric OTP
   → SET otp:{tenantId}:{normalizedEmail} {otp} EX 600
   → enqueue "send-email" job: { template: "otp", to: email, data: { otp, expiryMinutes: 10 } }
   → return { success: true, expiresAt: ... }
   (Never return the OTP in the response, even in dev. Check logs.)
 
2. POST /api/auth/otp/verify  { email: "...", otp: "123456" }
   → fetch from Redis: GET otp:{tenantId}:{email}
   → if null → { error: "otp_expired" }
   → if mismatch → decrement attempt counter, { error: "otp_invalid", attemptsLeft: N }
   → if 0 attempts left → DELETE key, { error: "otp_max_attempts" }
   → on match: DELETE key (one-time use)
   → run domain rule check
   → find or create user
   → issue tokens
   → return { accessToken, user }

OTP attempt limiting

// Redis keys for OTP:
// otp:{tenantId}:{email}           → the OTP value, expires in 10 min
// otp:attempts:{tenantId}:{email}  → attempt counter, expires in 10 min
 
const MAX_ATTEMPTS = 5;
const attempts = await redis.incr(`otp:attempts:${tenantId}:${email}`);
if (attempts === 1) await redis.expire(`otp:attempts:${tenantId}:${email}`, 600);
if (attempts > MAX_ATTEMPTS) throw new OtpMaxAttemptsError();

Domain rule check

Runs on every auth attempt (both OAuth and OTP) before creating or logging in a user.

interface DomainCheckResult {
  allowed: boolean;
  role: UserRole | null;
  reason: string;
}
 
async function checkDomainRules(email: string, tenant: TenantConfig): Promise<DomainCheckResult> {
  const domain = email.split('@')[1].toLowerCase();
  const normalizedEmail = email.toLowerCase();
 
  // 1. Check if domain is in allowedDomains
  if (!tenant.domainRules.allowedDomains.includes(domain)) {
    return { allowed: false, role: null, reason: 'domain_not_allowed' };
  }
 
  // 2. Check matchers (contains/endsWith rules)
  for (const matcher of tenant.domainRules.matchers) {
    if (matcher.contains && normalizedEmail.includes(matcher.contains)) {
      return { allowed: true, role: matcher.role, reason: 'matcher_hit' };
    }
    if (matcher.endsWith && normalizedEmail.endsWith(matcher.endsWith)) {
      return { allowed: true, role: matcher.role, reason: 'matcher_hit' };
    }
  }
 
  // 3. Check manual CSV allowlist
  const allowlistEntry = await prisma.allowlistEntry.findFirst({
    where: { tenantId: tenant.id, email: normalizedEmail },
  });
  if (allowlistEntry) {
    return { allowed: true, role: allowlistEntry.role, reason: 'allowlist' };
  }
 
  // 4. allowAnyFromDomain fallback
  if (tenant.domainRules.allowAnyFromDomain) {
    return { allowed: true, role: 'student', reason: 'domain_wildcard' };
  }
 
  return { allowed: false, role: null, reason: 'no_rule_matched' };
}

Find-or-create user

async function findOrCreateUser(email: string, name: string, tenantId: string, role: UserRole) {
  let user = await prisma.user.findUnique({
    where: { tenantId_email: { tenantId, email } }
  });
 
  if (user) {
    // Existing user — update last login, don't change role
    await prisma.user.update({
      where: { id: user.id },
      data: { lastLoginAt: new Date(), name }  // update name from OAuth if changed
    });
    return user;
  }
 
  // New user — create with role from domain rules
  user = await prisma.user.create({
    data: {
      tenantId,
      email,
      name,
      role,
    }
  });
 
  // If student, also create Student record
  if (role === 'student') {
    await prisma.student.create({
      data: {
        tenantId,
        userId: user.id,
        batchYear: inferBatchYear(email),  // heuristic from email pattern
        major: '',  // to be filled in profile
      }
    });
  }
 
  return user;
}

Token issuance

function issueTokenPair(user: User, tenant: Tenant): { accessToken: string; refreshToken: string } {
  const permissions = getPermissionsForRole(user.role);
 
  const accessToken = jwt.sign(
    {
      sub: user.id,
      tenant_id: tenant.id,
      tenant_slug: tenant.slug,
      role: user.role,
      permissions,
    },
    tenant.jwtSecret,
    { expiresIn: '15m', algorithm: 'HS256' }
  );
 
  const refreshToken = crypto.randomUUID();
  
  // Store refresh token in Redis
  // Key: refresh:{token}  Value: {userId}:{tenantId}
  redis.setex(
    `refresh:${refreshToken}`,
    30 * 24 * 60 * 60,  // 30 days
    `${user.id}:${tenant.id}`
  );
 
  return { accessToken, refreshToken };
}

Refresh token flow

POST /api/auth/refresh
Headers: Cookie: refresh_token={token}
OR Body: { refreshToken: "..." }
 
1. Extract token from cookie or body
2. GET refresh:{token} from Redis
3. If null → 401 { error: "refresh_token_invalid" } (expired or revoked)
4. Parse userId and tenantId from value
5. Load user + tenant from DB
6. If user inactive → 401 { error: "account_disabled" }
7. Issue new access token (same refresh token — rolling expiry optional)
8. Optionally: rotate refresh token (more secure but logs user out on concurrent tabs)
9. Return { accessToken }

Rotation strategy for MVP: Do not rotate refresh tokens. One refresh token per login session. Simpler to implement and debug.


Logout / token revocation

POST /api/auth/logout
Headers: Authorization: Bearer {accessToken}
 
1. Extract refresh token from cookie or body
2. DEL refresh:{token} from Redis → immediately invalid
3. Access token is stateless — it will expire naturally in ≤15 min
   (For immediate revocation: maintain a Redis blocklist SET revoked:access:{jti})
4. Clear refresh_token cookie
5. Return 200

For MVP: do not maintain access token blocklist. 15-min expiry is acceptable.

For super admin revoking another user's session: store a sessions_revoked_before:{userId} timestamp in Redis. Access token validator checks iat < sessions_revoked_before → reject.


reply.setCookie('refresh_token', refreshToken, {
  httpOnly: true,      // not accessible to JS
  secure: true,        // HTTPS only
  sameSite: 'strict',  // no CSRF
  maxAge: 30 * 24 * 60 * 60,  // 30 days in seconds
  path: '/api/auth',   // only sent to auth endpoints
});

Security notes

  • JWT signing: HS256 with a per-tenant secret (not a global secret). Compromise of one tenant's secret does not affect other tenants.
  • OTP: never log the OTP value. Never return it in API responses.
  • PKCE: always use for Google OAuth. Prevents code interception attacks.
  • Refresh tokens: store only the hash of the token in Redis for extra security (optional for MVP, recommended for prod).
  • Rate limiting on auth endpoints (handled by api-gateway): 5 OTP requests per 10 min per IP.