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.
- Never store passwords. Your app forwards credentials to IAM
loginand keeps only the tokens it returns. - Authorize on permissions, never role names. Check
"loan.approve", not"role == 'Branch Manager'". Roles change; permissions are your contract. - Filter data by the
scopeclaim. 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_tokeninstead of access/refresh tokens. Your app must present the organizations list, let the user choose, and callPOST /api/auth/select-organizationto 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) andpurpose: "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
401here means the session is dead; send the user back to login. - On sign-out call
POST /api/auth/logoutwith 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 200 — identical 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/verify—JWT_SECRET_KEYis never shared with consumer apps. - [ ] Authorization checks read
permissions, neverroles. - [ ] Every data query is filtered by the
scopeclaim; 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 callselect-organizationbefore storing tokens. - [ ] Passkey
/authenticate/completeand/register/completesend a nestedcredentialobject (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/loginPOST /api/auth/select-organizationPOST /api/auth/refreshPOST /api/auth/forgot-passwordPOST /api/auth/reset-passwordPOST /api/auth/change-passwordPOST /api/auth/verifyPOST /api/auth/passkeys/authenticate/beginPOST /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.