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 accountsMethod 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 200For 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.
Refresh token cookie settings
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:
HS256with 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.