IAM Integration Guide for Application Developers

This guide is for developers building a consumer application (LMS, CRM, Inventory, Ticketing, Collections, …) that needs to authenticate and authorize users against the shared IAM platform.

You do not build your own login, password storage, or roles. You delegate all of that to IAM and make decisions from the JWT it issues.

Full REST reference: src/docs/api.md · Runnable samples: src/docs/integration/ · Passkey demo page: src/docs/integration/passkey_login.html · User data spec: docs/user-data-spec.md


1. The three rules

Read these first — everything else follows from them.

  1. Never store passwords. Your app forwards credentials to IAM login and keeps only the tokens it returns.
  2. Authorize on permissions, never role names. Check "loan.approve", not "role == 'Branch Manager'". Roles change; permissions are your contract.
  3. Filter data by the scope claim. The JWT tells you which records a user may see (global / org / office branch). Apply it to every query.

2. How it fits together

┌──────────────┐   1. login (username/password)   ┌──────────────┐
│  Your App    │ ───────────────────────────────► │     IAM      │
│ (LMS/CRM/…)  │ ◄─────────────────────────────── │   platform   │
│              │   2a. single org → tokens         │              │
│              │   2b. multi-org → pending_token   │              │
│              │       + organizations list         │              │
│              │                                    │              │
│  [if 2b:     │   3. select-organization          │              │
│   user picks │ ───────────────────────────────► │              │
│   an org]    │ ◄─────────────────────────────── │              │
└──────┬───────┘   4. { access_token, refresh }   └──────────────┘
       │
       │ 5. Validate the access token LOCALLY (no network call)
       │    and read claims: permissions + scope
       ▼
   Allow / deny the action, then filter your own data by scope.

The access token is self-contained: it carries the user's identity, organization, roles, permissions, and data scope. After login you validate it locally on every request — you do not call IAM to check each action.

Multi-org users: When a user belongs to more than one organization, step 2 returns a short-lived pending_token instead of access/refresh tokens. Your app must present the organizations list, let the user choose, and call POST /api/auth/select-organization to complete login. See §3 for details.


3. Quick start (5 minutes)

Default base URL in development: http://localhost:5000.

Step 1 — Log the user in

curl -s -X POST http://localhost:5000/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username": "alice", "password": "Str0ng!Pass"}'

Single-org user — response (standard envelope — see §6):

{
  "success": true,
  "data": {
    "access_token": "eyJhbGci...",
    "refresh_token": "eyJhbGci...",
    "user": { "id": 42, "username": "alice", "email": "alice@example.com" },
    "organizations": [ { "id": 7, "name": "Acme", "code": "ACME" } ]
  }
}

Store both tokens against the user's session.

Multi-org user — if the user belongs to more than one organization, the response looks different:

{
  "success": true,
  "data": {
    "requires_org_selection": true,
    "pending_token": "eyJhbGci...",
    "user": { "id": 42, "username": "alice", "email": "alice@example.com" },
    "organizations": [
      { "id": 7, "name": "Acme", "code": "ACME" },
      { "id": 12, "name": "Widget Co", "code": "WIDGET" }
    ]
  }
}

No access or refresh tokens are issued yet. Present the organizations list to the user and let them choose. Then complete login with a second call:

curl -s -X POST http://localhost:5000/api/auth/select-organization \
  -H 'Content-Type: application/json' \
  -d '{"pending_token": "eyJhbGci...", "organization_id": 7}'

Response 200:

{
  "success": true,
  "data": {
    "access_token": "eyJhbGci...",
    "refresh_token": "a1b2c3d4...",
    "user": { "id": 42, "username": "alice", "email": "alice@example.com" },
    "organizations": [ ... ]
  }
}

The pending_token expires after 5 minutes. If it expires, the user must log in again. After this call, the flow is identical to a single-org login — store both tokens and proceed.

Step 2 — Validate the access token

Consumer apps validate tokens by calling the IAM /api/auth/verify endpoint. Never share IAM's JWT_SECRET_KEY — it would let a compromised consumer forge tokens for any user.

import requests

def verify(token: str) -> dict:
    resp = requests.post(
        "https://iam.example.com/api/auth/verify",
        headers={"Authorization": f"Bearer {token}"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()["data"]
// Node.js — fetch
async function verify(token) {
  const resp = await fetch("https://iam.example.com/api/auth/verify", {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!resp.ok) throw new Error("Token invalid");
  return await resp.json().then(r => r.data);
}

Step 3 — Authorize the action

claims = verify(access_token)

if "loan.approve" not in claims["permissions"]:
    raise PermissionError("Missing required permission: loan.approve")

# Allowed — now scope the data (see §5).

That's a complete integration. The rest of this guide fills in the details.


4. Token claims reference

jwt.decode(...) returns:

Claim Type Meaning
sub string User ID (note: string, e.g. "42")
org_id int | null Active organization the token is scoped to
roles string[] Role names — for display/audit only, don't authorize on these
permissions string[] The authorization contract, e.g. ["loan.view","loan.approve"]
scope object Data visibility — see §5
iat / exp int (epoch) Issued-at / expiry. Access tokens are short-lived (default 15 min, refresh tokens default 30 days) — configurable per-deployment via JWT_ACCESS_TOKEN_EXPIRY_MINUTES / JWT_REFRESH_TOKEN_EXPIRY_DAYS

A user belongs to one org per token. To act in a different org, call POST /api/auth/switch-organization to get a fresh token pair whose permissions and scope reflect that org.

Pending token (returned by login for multi-org users) is a minimal JWT containing only sub (user ID) and purpose: "org_selection". It carries no permissions, roles, or scope — it cannot be used as a Bearer token. It expires after 5 minutes.


5. The scope claim → your data filter

scope.level tells you how wide the user's visibility is. There are five levels, narrowest to broadest — translate the claim into a filter on your own tables:

scope.level Extra field Your query filter
OWN_BRANCH scope.office_id office_id == scope.office_id (or descendant match)
OWN_AREA scope.office_id office_id in that office + all descendant offices
OWN_REGION scope.office_id office_id in that office + all descendant offices
ORGANIZATION organization_id == org_id
GLOBAL no filter (sees everything)

OWN_BRANCH, OWN_AREA, and OWN_REGION all resolve the same way client-side — an office plus its descendants — they differ only in which level of the hierarchy the office sits at.

def visibility_filter(claims: dict) -> dict:
    scope = claims.get("scope", {})
    level = scope.get("level")
    if level == "GLOBAL":       return {}                                   # all
    if level == "ORGANIZATION": return {"organization_id": claims["org_id"]}
    if level in ("OWN_BRANCH", "OWN_AREA", "OWN_REGION"):
        return {"office_id": scope["office_id"]}   # or descendant match
    return {"deny": True}        # unknown/missing scope → show nothing

Fail closed: an unrecognized or missing scope must return no records, never all of them.


6. Response envelope & errors

Every IAM response is exactly one of:

{ "success": true,  "data": ... }
{ "success": false, "error": "message", "error_code": "optional_code" }

Common status codes you'll handle:

Code When What to do
400 malformed request (e.g. wrong current password) surface the message to the user
401 invalid credentials, expired/invalid token re-login or refresh (§7)
403 authenticated but lacks permission / org required show "not allowed"
404 resource not found show "not found"
409 conflict (e.g. max 5 passkeys reached) surface the message to the user
422 validation error / weak password surface the message to the user
423 account locked tell the user to wait / contact admin
429 rate limited back off and retry

error_code (when present) is a stable, snake_case machine-readable string — prefer matching on it over parsing error.


7. Session lifecycle (refresh & logout)

Access tokens expire quickly (~15 min). When a validate call reports the token expired, get a new pair with the refresh token instead of forcing re-login:

def refresh(refresh_token: str) -> dict:
    r = requests.post(f"{IAM}/api/auth/refresh",
                      json={"refresh_token": refresh_token}, timeout=10)
    r.raise_for_status()
    return r.json()["data"]   # { access_token, refresh_token }
  • Refresh rotates the token — always replace both stored tokens with the new pair. A 401 here means the session is dead; send the user back to login.
  • On sign-out call POST /api/auth/logout with the refresh token to revoke it.

A drop-in client that logs in and auto-refreshes on 401 is already written for you — copy src/docs/integration/lms_example.py (IamSession).


8. Endpoints you'll actually call

You only need the auth endpoints to integrate. The rest are for admin portals.

Method & path Auth Purpose
POST /api/auth/login Exchange credentials for tokens (or pending token if multi-org)
POST /api/auth/select-organization Complete multi-org login with chosen org + pending token
POST /api/auth/refresh Rotate an expiring token pair
POST /api/auth/logout Bearer Revoke the refresh token
GET /api/auth/me Bearer Current user, orgs, roles, permissions, scope
POST /api/auth/switch-organization Bearer Re-scope token to another org (post-login)
POST /api/auth/forgot-password Password reset flow (token logged in dev only)
POST /api/auth/reset-password Complete password reset with token
POST /api/auth/change-password Bearer Change password (revokes all other sessions)
POST /api/auth/verify Validate an access token (consumer apps)
POST /api/auth/passkeys/register/begin Bearer Start passkey registration (requires password re-entry)
POST /api/auth/passkeys/register/complete Bearer Finish passkey registration
POST /api/auth/passkeys/authenticate/begin Start passkey login
POST /api/auth/passkeys/authenticate/complete Finish passkey login → tokens
GET /api/auth/passkeys Bearer List own passkeys
DELETE /api/auth/passkeys/{id} Bearer Revoke own passkey

Authenticated requests use the header: Authorization: Bearer <access_token>, and JSON bodies must be Content-Type: application/json.

Full catalog (users, orgs, roles, audit logs): src/docs/api.md.


9. Passkey (WebAuthn) login

IAM supports passkey authentication (WebAuthn/FIDO2) as an additional login method. Users can register their device's fingerprint/face scanner or a hardware security key. Passkeys are supplementary — every user still has a password.

9.1 If you redirect to IAM for login

You don't need to do anything. Passkey login produces the exact same JWT as password login — same access_token, refresh_token, user, organizations shape. Your app validates the token the same way (§2–§5). The JWT does not indicate which credential type was used; your authorization logic is unchanged.

9.2 If you want a "Log in with passkey" button on your own site

Your frontend runs the WebAuthn browser ceremony and calls IAM's API directly. The flow has two steps:

┌──────────────┐   1. begin (username)           ┌──────────────┐
│  Your App    │ ───────────────────────────────► │     IAM      │
│  (browser)   │ ◄─────────────────────────────── │   platform   │
│              │   { challenge, allowCredentials } │              │
│              │                                   │              │
│   [user taps │   2. complete (signed response)   │              │
│    fingerprint] ──────────────────────────────► │              │
│              │ ◄─────────────────────────────── │              │
└──────────────┘   { access_token, refresh_token } └──────────────┘

Step 1 — Begin: ask IAM for a challenge

curl -s -X POST https://sims.technovative.in/api/auth/passkeys/authenticate/begin \
  -H 'Content-Type: application/json' \
  -d '{"username": "alice"}'

Response 200:

{
  "success": true,
  "data": {
    "options": {
      "rpId": "technovative.in",
      "challenge": "kXjiitAPySOF...base64...==",
      "allowCredentials": [
        { "type": "public-key", "id": "dGVzdC1jcmVk...base64..." }
      ]
    },
    "user_id": 42
  }
}

Errors: 401 unknown user · 400 no passkeys registered · 403 inactive · 423 locked.

Step 2 — Browser ceremony + complete

Pass the options to the browser's WebAuthn API, then send the result back to IAM. Here is the complete JavaScript — copy it into your login page:

/**
 * Log in with a passkey. Call this from your "Log in with passkey" button.
 *
 * @param {string} iamBaseUrl  - e.g. "https://sims.technovative.in"
 * @param {string} username    - the user's username or email
 * @returns {Promise<object>}  - { access_token, refresh_token, user, organizations }
 */
async function loginWithPasskey(iamBaseUrl, username) {

  // --- Step 1: Get a challenge from IAM ---
  const beginResp = await fetch(`${iamBaseUrl}/api/auth/passkeys/authenticate/begin`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username }),
  });
  if (!beginResp.ok) {
    const err = await beginResp.json();
    throw new Error(err.error || "Failed to begin passkey login");
  }
  const { options } = (await beginResp.json()).data;

  // --- Step 2: Convert base64 fields to ArrayBuffers for the browser API ---
  options.challenge = base64ToBuffer(options.challenge);
  if (options.allowCredentials) {
    options.allowCredentials = options.allowCredentials.map(cred => ({
      ...cred,
      id: base64ToBuffer(cred.id),
    }));
  }

  // --- Step 3: Browser prompts the user (fingerprint / face / PIN) ---
  const credential = await navigator.credentials.get({ publicKey: options });

  // --- Step 4: Send the signed response back to IAM ---
  // IMPORTANT: The credential object must match the shape expected by
  // webauthn library's verify_authentication_response, with base64url
  // values for all binary fields (not individual flat fields).
  const credentialPayload = {
    id: bufferToBase64url(credential.rawId),
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON:    bufferToBase64(credential.response.clientDataJSON),
      authenticatorData: bufferToBase64(credential.response.authenticatorData),
      signature:         bufferToBase64(credential.response.signature),
    },
  };
  const completeResp = await fetch(`${iamBaseUrl}/api/auth/passkeys/authenticate/complete`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ credential: credentialPayload }),
  });
  if (!completeResp.ok) {
    const err = await completeResp.json();
    throw new Error(err.error || "Passkey verification failed");
  }

  // --- Done — same shape as POST /api/auth/login ---
  return (await completeResp.json()).data;
  // { access_token, refresh_token, user, organizations }
}

// --- Helper: base64 string → ArrayBuffer ---
function base64ToBuffer(b64) {
  const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes.buffer;
}

// --- Helper: ArrayBuffer → standard base64 ---
function bufferToBase64(buf) {
  const bytes = new Uint8Array(buf);
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary);
}

// --- Helper: ArrayBuffer → base64url (no padding) ---
function bufferToBase64url(buf) {
  return bufferToBase64(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

Usage in your login page:

document.getElementById("passkey-btn").addEventListener("click", async () => {
  try {
    const result = await loginWithPasskey("https://sims.technovative.in", usernameInput.value);
    // Store result.access_token and result.refresh_token — same as password login
    localStorage.setItem("access_token", result.access_token);
    localStorage.setItem("refresh_token", result.refresh_token);
    window.location.href = "/dashboard";
  } catch (err) {
    alert(err.message);
  }
});

Complete response (step 4)

Response 200identical to POST /api/auth/login:

{
  "success": true,
  "data": {
    "access_token": "eyJhbGci...",
    "refresh_token": "a1b2c3d4...",
    "user": { "id": 42, "username": "alice", "email": "alice@example.com" },
    "organizations": [ { "id": 7, "name": "Acme", "code": "ACME" } ]
  }
}

Errors: 401 invalid credential · 400 challenge expired · 423 locked.

From this point on, your app uses the tokens exactly as if the user logged in with a password. Token refresh, logout, scope filtering — everything in §3–§7 applies unchanged.

9.3 Passkey registration (optional)

If you want users to register passkeys from within your app (instead of sending them to IAM's settings page), the flow is similar but requires the user to be already authenticated and to re-enter their password:

/**
 * Register a new passkey for the current user.
 *
 * @param {string} iamBaseUrl    - e.g. "https://sims.technovative.in"
 * @param {string} accessToken   - the user's current access token
 * @param {string} password      - the user's current password (re-authentication)
 * @param {string} nickname      - a label for the passkey, e.g. "Work MacBook"
 * @returns {Promise<object>}    - { id, nickname, created_at }
 */
async function registerPasskey(iamBaseUrl, accessToken, password, nickname) {
  const headers = {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${accessToken}`,
  };

  // --- Step 1: Begin registration (re-authenticates with password) ---
  const beginResp = await fetch(`${iamBaseUrl}/api/auth/passkeys/register/begin`, {
    method: "POST", headers,
    body: JSON.stringify({ password }),
  });
  if (!beginResp.ok) {
    const err = await beginResp.json();
    throw new Error(err.error || "Failed to begin registration");
  }
  const { options } = (await beginResp.json()).data;

  // --- Step 2: Convert for browser API ---
  options.challenge = base64ToBuffer(options.challenge);
  options.user.id = base64ToBuffer(options.user.id);
  if (options.excludeCredentials) {
    options.excludeCredentials = options.excludeCredentials.map(c => ({
      ...c, id: base64ToBuffer(c.id),
    }));
  }

  // --- Step 3: Browser creates the credential ---
  const credential = await navigator.credentials.create({ publicKey: options });

  // --- Step 4: Send attestation to IAM ---
  // IMPORTANT: The credential object must match the shape expected by
  // webauthn library's verify_registration_response.
  const attestationResponse = credential.response;
  const credentialPayload = {
    id: bufferToBase64url(credential.rawId),
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON:    bufferToBase64(attestationResponse.clientDataJSON),
      attestationObject: bufferToBase64(attestationResponse.attestationObject),
    },
  };
  const completeResp = await fetch(`${iamBaseUrl}/api/auth/passkeys/register/complete`, {
    method: "POST", headers,
    body: JSON.stringify({
      credential: credentialPayload,
      nickname:   nickname,
      transports: credential.response.getTransports?.() || [],
    }),
  });
  if (!completeResp.ok) {
    const err = await completeResp.json();
    throw new Error(err.error || "Registration failed");
  }
  return (await completeResp.json()).data;
  // { id: 1, nickname: "Work MacBook", created_at: "2026-06-25T..." }
}

Errors: 401 wrong password / not authenticated · 409 max 5 passkeys reached · 400 challenge expired.

9.4 Credential management endpoints

Users can list and revoke their own passkeys. Admins can manage any user's passkeys. All endpoints require Authorization: Bearer <access_token>.

Method Path Who Purpose
GET /api/auth/passkeys user List own passkeys
DELETE /api/auth/passkeys/{id} user Revoke own passkey
GET /api/admin/users/{user_id}/passkeys admin (user.view) List user's passkeys
DELETE /api/admin/users/{user_id}/passkeys/{id} admin (user.manage) Revoke user's passkey

List response:

{
  "success": true,
  "data": [
    { "id": 1, "nickname": "Work MacBook", "created_at": "2026-06-25T10:30:00", "last_used_at": "2026-06-25T14:15:00" },
    { "id": 3, "nickname": "YubiKey",      "created_at": "2026-06-20T08:00:00", "last_used_at": null }
  ]
}

No public keys or credential IDs are exposed. last_used_at is null if the passkey has never been used for login.

9.5 Subdomain note

IAM's WEBAUTHN_RP_ID is set to the registrable domain (e.g. technovative.in), so passkeys work across all subdomains. Your app on crm.technovative.in can run the WebAuthn ceremony directly because the RP ID is a valid parent of your origin. You do not need to be on IAM's own subdomain.

9.6 Known limitation — multi-org users

Passkey authentication does not currently support the multi-org selection flow. If a user belongs to multiple organizations and logs in with a passkey, the token is scoped to their first organization only. To access another org they must call POST /api/auth/switch-organization after login. This will be addressed in a future release.

9.7 Quick reference: what the JWT does NOT tell you

The JWT is identical regardless of whether the user logged in with a password or a passkey. There is no amr claim or auth-method indicator. Your authorization logic — permissions, scope, token refresh, logout — is completely unchanged. If you have a working password-based integration, passkeys are free.


10. User profile fields & CORS

10.1 The user object has more fields than shown above

The JSON examples in this guide (§3, §9.2, §9.5) show user as { id, username, email } for brevity, but every endpoint that returns a user (login, select-organization, switch-organization, refresh, GET /api/auth/me) actually returns the full profile: uuid, mobile, first_name, middle_name, last_name, status, last_login_at, created_at, plus the optional profile/employment/locale fields — date_of_birth, gender, profile_photo_document_id, alternate_mobile, address_line1, address_line2, city, state, postal_code, country, designation, joining_date, timezone, preferred_language.

Field meanings, types, and which ones are required vs. optional are documented in docs/user-data-spec.md — check it before assuming a field IAM doesn't capture (it also has a feedback form for requesting new fields).

10.2 CORS allowlist

If your app calls the IAM API directly from the browser (not just server-to-server), your origin must be on the CORS allowlist (ALLOWED_CORS_ORIGINS) or the browser will block the request even with a valid token. There is no wildcard and no credentialed/cookie mode — IAM authenticates via Authorization: Bearer <token> only. Ask the platform team to add your origin before you start browser-side integration testing.


11. Integration checklist

  • [ ] Tokens validated via POST /api/auth/verifyJWT_SECRET_KEY is never shared with consumer apps.
  • [ ] Authorization checks read permissions, never roles.
  • [ ] Every data query is filtered by the scope claim; unknown scope → deny. Handle all five levels — OWN_BRANCH, OWN_AREA, OWN_REGION, ORGANIZATION, GLOBAL — not just the org/branch cases.
  • [ ] If calling the API from the browser, your origin is on the CORS allowlist (§10.2).
  • [ ] Expired access tokens trigger a refresh, and both tokens are replaced.
  • [ ] Logout revokes the refresh token server-side.
  • [ ] Your app stores no passwords.
  • [ ] Login handles requires_org_selection: when true, show the organizations list and call select-organization before storing tokens.
  • [ ] Passkey /authenticate/complete and /register/complete send a nested credential object (not flat base64 fields) — see §9 for exact shape.

12. Rate limiting

Sensitive auth endpoints are rate-limited per-IP (10 requests per 60-second window). The following paths are throttled:

  • POST /api/auth/login
  • POST /api/auth/select-organization
  • POST /api/auth/refresh
  • POST /api/auth/forgot-password
  • POST /api/auth/reset-password
  • POST /api/auth/change-password
  • POST /api/auth/verify
  • POST /api/auth/passkeys/authenticate/begin
  • POST /api/auth/passkeys/authenticate/complete

When rate-limited your client receives HTTP 429 with:

{ "success": false, "error": "Too many requests", "error_code": "rate_limited" }

Implement exponential backoff when handling 429 responses.


13. Environment setup for integration development

Quick-start with the dev server

git clone <repo-url> sims
cd sims
python3 -m venv .venv
source .venv/bin/activate
make install
cp .env.example .env
# Generate real secrets (optional for local dev):
python3 -c "import secrets; print('SECRET_KEY=' + secrets.token_hex(32))"
python3 -c "import secrets; print('JWT_SECRET_KEY=' + secrets.token_hex(32))"
make run
# → http://127.0.0.1:5000/api/health

Seed data

Run the seed command to populate standard roles and permissions:

flask --app src.app seed

Creating an admin user

flask --app src.app create-admin \
  --username admin \
  --email admin@example.com \
  --password Str0ng!Pass

14. A note on keys (HS256 → RS256)

Consumer apps must not share JWT_SECRET_KEY. Always validate tokens via the POST /api/auth/verify endpoint (see the integration examples in src/docs/integration/). This decouples consumers from the signing key and makes a future switch to RS256 transparent: IAM keeps the private key and distributes only the public key to consumers. The validation code inside IAM is identical apart from the algorithm — so isolate verify() behind one function and the switch is a one-line change for the platform.


Back to documentation index