# Certula Connect — Implementation Guide for LLMs

> Paste this file into any AI assistant to implement "Login with Certula" OAuth 2.0 + OIDC.

## Overview

Certula Connect is an OAuth 2.0 + OpenID Connect (OIDC) identity provider.
- Auth server: `https://certula.com`
- Flow: Authorization Code + PKCE (S256 only — plain is rejected)
- ID tokens: RS256 JWT signed by Certula's private key
- 2FA: **Coming Soon** — not yet available

## Prerequisites

1. Register a Connected App at certula.com/dashboard
2. You receive: `client_id` (public), `client_secret` (server-side only), `webhook_secret`
3. Register exact `redirect_uri` values (no wildcards)

## Security Rules

- `client_secret` → server-side ONLY. Never in frontend JS, React Native bundles, or public repos.
- `X-API-Key` (eKYC) → server-side ONLY. Different product, see certula-ekyc-llm.md.
- Never skip PKCE. Never use the `plain` method.
- Validate `state` on every callback to prevent CSRF.
- Use `sub` (not email) as the stable foreign key for users.

## PKCE Helper (use in all flows)

### TypeScript / Browser
```ts
function generateCodeVerifier(): string {
  const arr = new Uint8Array(32)
  crypto.getRandomValues(arr)
  return btoa(String.fromCharCode(...arr)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')
}
async function generateCodeChallenge(verifier: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
  return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')
}
```

### Node.js (server-side)
```js
const crypto = require('crypto')
const verifier  = crypto.randomBytes(32).toString('base64url')
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
```

### Python
```python
import secrets, hashlib, base64
def pkce_pair():
    v = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
    c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode()
    return v, c
```

## Step 1: Build Authorization URL

```
GET https://api.certula.com/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/auth/callback
  &scope=openid profile email
  &state=RANDOM_UUID
  &code_challenge=BASE64URL_SHA256_VERIFIER
  &code_challenge_method=S256
```

Store `state` and `code_verifier` in the session/cookie BEFORE redirecting.

## Step 2: Token Exchange (server-side)

```
POST https://api.certula.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE_FROM_CALLBACK
&redirect_uri=https://yourapp.com/auth/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=STORED_VERIFIER
```

Response: `{ access_token, refresh_token, id_token, expires_in: 900, token_type: "bearer" }`

## Step 3: Get User Info

```
GET https://api.certula.com/oauth/userinfo
Authorization: Bearer ACCESS_TOKEN
```

Response: `{ sub, email, email_verified, name, phone, phone_verified, kyc_verified, picture }`

Always use `sub` as the foreign key. It is permanent. Email can change.

## Token Refresh

```
POST https://api.certula.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=STORED_REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
```

Refresh tokens are ROTATED on each use — always persist the new one from the response.

## Token Revocation (logout)

```
POST https://api.certula.com/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=REFRESH_TOKEN&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET
```

## Token Lifetimes

| Token | Lifetime |
|---|---|
| Authorization Code | 10 min, single use |
| Access Token | 15 min (900s), RS256 JWT |
| Refresh Token | 7 days (30d with remember_me), rotated |
| OTP Code | 10 min |

## JWKS / JWT Verification

```
GET https://api.certula.com/oauth/.well-known/jwks.json
```

### Node.js (jose library)
```ts
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://api.certula.com/oauth/.well-known/jwks.json'))
const { payload } = await jwtVerify(token, JWKS, {
  issuer: 'https://certula.com',
  audience: process.env.CERTULA_CLIENT_ID,
})
```

### Python (PyJWT)
```python
import jwt
from functools import lru_cache

@lru_cache(maxsize=1)
def _jwks(): return jwt.PyJWKClient('https://api.certula.com/oauth/.well-known/jwks.json')

def verify(token: str) -> dict:
    key = _jwks().get_signing_key_from_jwt(token)
    return jwt.decode(token, key.key, algorithms=['RS256'],
                      issuer='https://certula.com', audience=settings.certula_client_id)
```

## Scopes

| Scope | Claims |
|---|---|
| openid | sub, iat, exp (required) |
| profile | name, picture |
| email | email, email_verified |
| phone | phone, phone_verified |
| kyc | kyc_verified |

## Callback Validation

```js
// Express callback handler
router.get('/callback', async (req, res) => {
  const { code, state, error } = req.query
  if (error) return res.redirect(`/login?error=${error}`)
  if (state !== req.session.certulaState) return res.status(400).send('Bad state')
  // ... token exchange ...
})
```

## OIDC Discovery

```
GET https://api.certula.com/oauth/.well-known/openid-configuration
```

Returns all endpoint URLs for auto-configuration of OIDC-compatible libraries.

## Embedded Auth Flow (optional)

For custom login UI inside your app (instead of redirect to certula.com):

| Endpoint | Purpose |
|---|---|
| POST /oauth/flow/check-email | Determine: password / otp_login / register |
| POST /oauth/flow/login | Login with password → returns authorization_code |
| POST /oauth/flow/otp-login | Login with email OTP → returns authorization_code |
| POST /oauth/flow/register | Register new user → returns authorization_code |
| POST /oauth/flow/send-otp | Send email OTP |
| POST /oauth/flow/resend-otp | Resend email OTP |
| POST /oauth/flow/send-phone-otp | Send SMS OTP |
| POST /oauth/flow/verify-phone-otp | Verify SMS OTP |
| POST /oauth/flow/forgot-password | Send reset email |
| POST /oauth/flow/validate-reset-token | Check reset token |
| POST /oauth/flow/reset-password | Set new password |

All embedded flow endpoints return `{ authorization_code, expires_in: 600 }`.
Use this code in the same `/oauth/token` exchange (grant_type=authorization_code).

## 2FA Status

**NOT yet available.** TOTP (authenticator app) and WebAuthn are coming soon.
SMS OTP as a *primary login* method (passwordless) is available via `/oauth/flow/otp-login`.

## Error Format

```json
{ "error": "invalid_grant", "error_description": "Authorization code has expired" }
```

| error | Cause |
|---|---|
| invalid_client | Wrong client_id or client_secret |
| invalid_grant | Code expired/used, or code_verifier mismatch |
| invalid_request | Missing parameter |
| invalid_scope | Scope not allowed for this client |
| redirect_uri_mismatch | URI not registered |
| access_denied | User cancelled (arrives as URL query param) |

## React Native Note

Use `expo-auth-session` with `usePKCE: true`. Route token exchange through your backend — never embed `client_secret` in a mobile app bundle.

## Environment Variables

```
CERTULA_CLIENT_ID=ca_yourapp_...      # public, safe in NEXT_PUBLIC_
CERTULA_CLIENT_SECRET=cs_...          # server-side only, never public
CERTULA_WEBHOOK_SECRET=ws_...         # for HMAC webhook verification
```

---

# PART 2: Certula Verify (eKYC Identity Verification)

> This section covers Certula Verify — a separate product from Certula Connect.
> Different credentials (X-API-Key, not OAuth), different endpoints, different use case.

## Overview

Certula Verify is a hosted eKYC (electronic Know Your Customer) service.
Your backend creates a session → user is redirected to Certula's verification UI → you check the result.

- Session API base: `CERTULA_SERVER_URL` (e.g. `https://api.certula.com`)
- Frontend redirect base: `CERTULA_FRONTEND_URL` (e.g. `https://certula.com`)
- Auth: `X-API-Key` header — server-side only. Never expose to browser or mobile bundle.
- Workflow ID: must be exactly `"identity_verification"` — no other value is accepted.

## Connect vs Verify — Key Differences

| | Certula Connect | Certula Verify |
|---|---|---|
| Auth method | `client_id` + `client_secret` | `X-API-Key` |
| Use case | "Login with Certula" | "Verify my identity" |
| Result | access token + user profile | kyc_verified flag + document data |
| Main endpoint | `/oauth/authorize` | `/api/v1/sessions/` |

## Step 1: Create a Verification Session (server-side)

```js
// Node.js
const res = await fetch(`${process.env.CERTULA_SERVER_URL}/api/v1/sessions/`, {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.CERTULA_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    applicant_id:            user.certula_id,    // "sub" from Connect userinfo
    user_email:              user.email,
    user_name:               user.name,
    workflow_id:             'identity_verification',  // MUST be this exact value
    redirect_url:            'https://yourapp.com/verification/complete',
    language:                'en',
    country:                 'MY',
    send_email_notification: false,
    metadata:                { user_id: user.id, source: 'my-app' },
  }),
})
const { session_token, expires_at } = await res.json()
const verificationUrl = `${process.env.CERTULA_FRONTEND_URL}/ekyc?session=${session_token}`
// Redirect the user to verificationUrl
```

```python
# Python (FastAPI)
import httpx

session_data = {
    "applicant_id":            user.certula_id or str(user.id),
    "user_email":              user.email,
    "user_name":               user.display_name or user.full_name,
    "workflow_id":             "identity_verification",
    "redirect_url":            f"{settings.app_url}/verification/complete",
    "language":                "en",
    "country":                 "MY",
    "send_email_notification": False,
    "metadata":                {"user_id": str(user.id), "source": "my-app"},
}
async with httpx.AsyncClient(timeout=10) as client:
    resp = await client.post(
        f"{settings.certula_server_url}/api/v1/sessions/",
        json=session_data,
        headers={"X-API-Key": settings.certula_api_key, "Content-Type": "application/json"},
    )
resp.raise_for_status()
session_token = resp.json()["session_token"]
verification_url = f"{settings.certula_frontend_url}/ekyc?session={session_token}"
```

## Step 2: Redirect the User

```
HTTP 302 → {CERTULA_FRONTEND_URL}/ekyc?session={session_token}
```

The user scans their document, completes liveness check, and Certula redirects them
back to your `redirect_url`.

**WARNING**: The redirect back happens before Certula finishes processing.
ALWAYS call the status API to confirm — never treat the redirect alone as success.

## Step 3: Check KYC Status (after user returns)

```
GET {CERTULA_SERVER_URL}/api/v1/users/{certula_user_id}/profile
X-API-Key: YOUR_API_KEY
```

```js
// Node.js status normalizer — response field names vary across API versions
function resolveKycStatus(profile) {
  for (const k of ['kyc_verified', 'ekyc_verified', 'kyc_status', 'ekyc_status']) {
    if (profile[k] != null) {
      const v = profile[k]
      if (typeof v === 'boolean') return v
      return ['verified', 'approved', 'passed', 'complete'].includes(String(v).toLowerCase())
    }
  }
  return false
}
```

```python
# Python normalizer
def resolve_kyc_status(payload: dict) -> bool:
    for key in ("kyc_verified", "ekyc_verified", "kyc_status", "ekyc_status"):
        if key in payload:
            val = payload[key]
            if isinstance(val, bool): return val
            return str(val).lower() in {"verified", "approved", "passed", "complete"}
    return False
```

## Get Detailed Identity Data (optional)

Requires `kyc.details` scope on your connected app. Returns extracted document data (PII).

```
GET {CERTULA_SERVER_URL}/api/v1/apps/me/user/{user_id}?include_details=true
X-API-Key: YOUR_API_KEY
```

Response includes: `kyc_details.extracted_data.full_name`, `id_number`, `date_of_birth`, `address_line_1`, etc.

**Only request this when genuinely needed. Extracted identity data is highly sensitive PII.**
**Never log the full response body.**

## All Verify Endpoints

| Method | Path | Description |
|---|---|---|
| POST | `/api/v1/sessions/` | Create a verification session |
| GET | `/api/v1/users/{certula_id}/profile` | Check KYC status |
| GET | `/api/v1/apps/me/user/{user_id}?include_details=true` | Get extracted identity data |

## Create Session — Required Body Fields

| Field | Type | Notes |
|---|---|---|
| `applicant_id` | string | User's Certula ID (`sub` from userinfo) or your internal UUID |
| `user_email` | string | User's email |
| `user_name` | string | Display name shown on Certula's UI |
| `workflow_id` | string | Must be exactly `"identity_verification"` |
| `redirect_url` | string | Where to send the user after verification attempt |
| `language` | string | Optional. `"en"` / `"ms"` / `"zh"`. Default: `"en"` |
| `country` | string | Optional. ISO code. Default: `"MY"` |
| `send_email_notification` | boolean | Optional. Default: `false` |
| `metadata` | object | Optional. Stored with session. Put your `user_id` + `source` here. |

## Environment Variables

```
CERTULA_API_KEY=...            # server-side only, never public
CERTULA_SERVER_URL=...         # API base URL (e.g. https://api.certula.com)
CERTULA_FRONTEND_URL=...       # Frontend base URL for building the redirect (e.g. https://certula.com)
```

## Error Reference

| HTTP | Cause | Fix |
|---|---|---|
| 401 | Invalid or missing X-API-Key | Check CERTULA_API_KEY, regenerate if needed |
| 403 | Missing `kyc.details` scope | Contact Certula to enable scope |
| 404 | User not found | Use `sub` from OAuth userinfo; check user has Certula account |
| 422 | Missing required field | Check all required body fields in session creation |
| 429 | Rate limit | Exponential backoff |
| 503 | Service unavailable | Retry with backoff; fall back to cached verification status |

## Compliance Checklist (PDPA / GDPR)

- [ ] Encrypt identity data at rest (AES-256+)
- [ ] Never log response bodies that contain PII (id_number, date_of_birth, address)
- [ ] Only call `include_details=true` when the document fields are actually needed
- [ ] Define and enforce a data retention policy
- [ ] Obtain and record explicit user consent before initiating eKYC
- [ ] Store API key in a secrets manager; rotate on schedule
- [ ] Log audit trail: (app_id, user_id, endpoint, timestamp, was include_details used) — not the response body
