Skip to main content

Authentication

SyncAD uses a layered authentication system: OTP for initial identity verification, JWT for API authorization, PIN for quick re-entry on mobile, and optional biometrics for frictionless login.

Auth Flow by Platform

Mobile Apps (Parents, Teachers, Driver)

┌─────────┐   Phone       ┌──────────────┐  OTP verified   ┌──────────────┐
│ User │──────────────►│ POST /auth │────────────────►│ Store JWT │
│ App │ │ /send-otp │ + Refresh Token│ + PIN │
└─────────┘ └──────────────┘ └──────────────┘

┌─────────┐ OTP/PIN ┌──────────────┐ JWT attached ┌──────────────┐
│ User │──────────────►│ POST /auth │────────────────►│ API Request │
│ App │ │ /verify-otp │ + schoolId │ Authorized │
└─────────┘ └──────────────┘ └──────────────┘

Web Admin

┌─────────┐   Email/Password   ┌──────────────┐  JWT attached   ┌──────────────┐
│ Admin │───────────────────►│ POST /auth │───────────────►│ Dashboard │
│ UI │ │ /login │ │ Authorized │
└─────────┘ └──────────────┘ └──────────────┘

OTP Flow

Step 1 — Request OTP

POST /{role}/user-auth/send-otp
Content-Type: application/json

{ "phone": "+919876543210" }

API generates a 6-digit OTP, stores hashed in Redis (TTL 5 minutes), and dispatches via SMS provider (AWS SNS).

Step 2 — Verify OTP

POST /{role}/user-auth/verify-otp
Content-Type: application/json

{ "phone": "+919876543210", "otp": "123456" }

On success, returns:

{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "uuid",
"phone": "+919876543210",
"role": "PARENT",
"schoolId": "school-uuid"
}
}

Step 3 — Set PIN (first time only)

POST /{role}/user-auth/set-pin
Authorization: Bearer <accessToken>

{ "pin": "1234" }

PIN is stored as a bcrypt hash. Subsequent logins can use PIN instead of re-sending OTP.

Step 4 — Quick Re-Entry with PIN

POST /{role}/user-auth/verify-pin
Content-Type: application/json

{ "phone": "+919876543210", "pin": "1234" }

Returns new access + refresh tokens without SMS.

JWT Structure

// Access Token (15 minute expiry)
{
"sub": "user-uuid",
"role": "TEACHER",
"schoolId": "school-uuid",
"iat": 1712000000,
"exp": 1712000900 // +15 min
}

// Refresh Token (7 day expiry)
{
"sub": "user-uuid",
"type": "refresh",
"iat": 1712000000,
"exp": 1712600000 // +7 days
}

Token Refresh

When the access token expires, clients call:

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

{ "refreshToken": "eyJhbGciOiJIUzI1NiIs..." }

Old refresh token is invalidated (rotation). New access + refresh tokens are issued.

RBAC — Role Module Permissions

// packages/db/src/schema.ts
export const RoleModulePermission = sqliteTable('Role_Module_Permission', {
id: text('id').primaryKey(),
roleId: text('roleId').notNull().references(() => Role.id),
moduleId: text('moduleId').notNull().references(() => Module.id),
canRead: integer('canRead', { mode: 'boolean' }).default(false),
canWrite: integer('canWrite', { mode: 'boolean' }).default(false),
canDelete: integer('canDelete', { mode: 'boolean' }).default(false),
});

export const Module = sqliteTable('Module', {
id: text('id').primaryKey(),
name: text('name').notNull(), // attendance, exam, fee, leave, ...
displayName: text('displayName').notNull(),
});

Role Types

RoleDescription
SCHOOL_ADMINFull access to school admin UI
SUPER_ADMINCross-school, provisioning access
TEACHERPer-module read/write as assigned
PARENTRead-only for linked students
DRIVERBus tracking module only

Teachers — Module-Level Permissions

Teachers have fine-grained permissions per module:

// Example: check if teacher can mark attendance
const hasWritePermission = await roleRepo.hasPermission(user.roleId, 'attendance', 'write');

// Teacher's effective permission = role permission AND their class/division assignment

Biometrics (Parents & Teachers)

On supported devices, after initial PIN setup:

// Using local_auth package
final authenticated = await LocalAuthentication().authenticate(
localizedReason: 'Authenticate to access SyncAD',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,
),
);

if (authenticated) {
// Use stored access token directly — no PIN/OTP needed
}

Biometrics unlock the stored JWT, not a separate auth mechanism.

PassportJS Strategies

// apps/api/src/modules/auth/strategies/
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.jwtSecret,
});
}

async validate(payload: JwtPayload) {
const user = await userService.findById(payload.sub);
if (!user) throw new UnauthorizedException();
return user;
}
}

Evaluation Mode (Teachers App)

When evaluationMode=true is set at the school level, all teacher modules except Exam Management become read-only. This is enforced at the interceptor level:

// EvaluationInterceptor in teachers app
if (response.statusCode == 403 &&
response.data['error'] == 'EVALUATION_MODE_READONLY') {
// Show read-only UI, disable write buttons
// Re-fetch permissions from AuthProvider
}

Evaluation Mode restricts teachers to read-only on most modules during exam periods. See the Teachers App docs for the full module impact table.