Identity Demo

Embed the widget, authenticate a user, connect a company via OAuth, and commit KYB/KYC/transaction profile data to the Identity Provider using a company-issued access token.

Live Demo

Try the authentication flow with our test credentials

Loading widget...

Demo Controls

Restart the authentication flow from the beginning

Widget Status

Initializing...

Integration Guide

Embed the widget in your application

Basic Iframe Integration

<!-- Embed the widget -->
<iframe
  src="https://arn-widget-production.up.railway.app"
  title="ARN Authentication Widget"
  width="100%"
  height="600px"
  frameborder="0"
  sandbox="allow-scripts allow-same-origin allow-forms"
></iframe>

The widget handles authentication internally. Use postMessage to pass configuration and receive events.

Passing config (recommended)

// Host app → widget (iframe)
const widgetOrigin = new URL('https://arn-widget-production.up.railway.app').origin;
iframe.contentWindow.postMessage({
  type: 'GRAILVAULT_CONFIG',
  payload: {
    apiBaseUrl: 'https://<identity-provider-host>',
    accessToken: null, // widget can obtain via its login flow
    oauth: {
      clientId: '<CompanyA OAuth client_id (uid)>',
      redirectUri: 'https://company-a.com/oauth/callback',
      scope: 'kyc.write kyb.write transactions.write',
      state: '<generated-by-company-backend>'
    }
  }
}, widgetOrigin);

Important: never put the OAuth client_secret in the browser. The company backend must generate/validate state.

Listening for events

// Widget → host app
window.addEventListener('message', (event) => {
  const widgetOrigin = new URL('https://arn-widget-production.up.railway.app').origin;
  if (event.origin !== widgetOrigin) return;
  if (event.source !== iframe.contentWindow) return;

  const { type, payload } = event.data || {};
  if (type === 'AUTH_COMPLETE') console.log('User logged in / account selected', payload);
  if (type === 'COMPANY_CONNECTED') console.log('Company OAuth access granted', payload);
  if (type === 'STEP_CHANGE') console.log('Widget step changed', payload);
  if (type === 'ERROR') console.error('Widget error', payload);
});

Event payloads

All events use the same envelope shape: { type, payload }. Payload examples below match what the widget emits today.

// AUTH_COMPLETE
// - Emitted after login/registration verification:
{
  "type": "AUTH_COMPLETE",
  "payload": { "accessToken": "<JWT>", "authenticated": true }
}

// - Emitted after the user selects an account (demo compatibility):
{
  "type": "AUTH_COMPLETE",
  "payload": {
    "account": "<MASKED_ACCOUNT_NUMBER>",
    "routing": "<MASKED_ROUTING_NUMBER>",
    "accountLast4": "1234",
    "routingLast4": "5678"
  }
}

// COMPANY_CONNECTED (OAuth consent confirmed)
{
  "type": "COMPANY_CONNECTED",
  "payload": { "connected": true }
}

// STEP_CHANGE
{
  "type": "STEP_CHANGE",
  "payload": { "step": "email-entry" }
}

// ERROR
{
  "type": "ERROR",
  "payload": { "message": "Human readable error message" }
}

React Component Usage

import { ARNWidget } from '@grailpay/arn-widget';

function App() {
  const handleAuthComplete = (accountData) => {
    console.log('Authentication complete:', accountData);
  };

  return (
    <ARNWidget
      apiUrl="https://identity-provider.railway.app"
      onAuthComplete={handleAuthComplete}
    />
  );
}

NPM package coming soon. For now, use iframe embedding.

OAuth + Data Contribution

1) Create Company + OAuth client (admin)

In the Identity Provider admin UI, create a Company (e.g. “Company A”), then create an OAuth Application under that company to obtain client_id and client_secret.

2) Company backend: OAuth callback + code exchange

// Company backend receives redirect with ?code=...&state=...
// Validate `state`, then exchange code for tokens:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<AUTH_CODE>
&redirect_uri=https%3A%2F%2Fcompany-a.com%2Foauth%2Fcallback
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>

Store the returned access_token (and refresh_token) server-side, associated to the user in Company A’s system.

3) Company backend: commit profile data

Send the CommitUserProfileRequest payload to the Identity Provider using the user-scoped OAuth access token.

POST /api/v1/third-party/data
Authorization: Bearer <OAUTH_ACCESS_TOKEN>
Content-Type: application/json

{
  "accountId": "company-a-user-123",
  "idempotencyKey": "optional-key",
  "source": {
    "system": "company_portal",
    "provider": "company_a",
    "eventId": "evt_123",
    "capturedAt": "2026-01-02T12:00:00Z"
  },
  "profile": {
    "kyc": { "name": "Steve" },
    "transactions": [
      {
        "transactionDate": "2026-01-01",
        "amount": { "value": 10.25, "currency": "USD" },
        "direction": "debit"
      }
    ]
  }
}

Scopes are enforced by what’s present in profile (top-level keys):

// profile key → required OAuth scope
kyc                   → kyc.write
kyb                   → kyb.write
transactions          → transactions.write
financialInstitutions → transactions.write

CommitUserProfileRequest schema

Canonical JSON Schema: identity-provider/schemas/commit-user-profile-request.schema.json (and referenced in identity-provider/API.md). Note: the server currently enforces only accountId, non-empty profile, and OAuth scopes based on profile top-level keys.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.grailpay.com/auth/commit-user-profile-request.schema.json",
  "title": "CommitUserProfileRequest",
  "type": "object",
  "additionalProperties": false,
  "required": ["accountId", "profile"],
  "properties": {
    "accountId": {
      "type": "string",
      "minLength": 1,
      "description": "User account identifier to attach the data to."
    },
    "idempotencyKey": {
      "type": "string",
      "minLength": 1,
      "description": "Client-provided key to make this commit idempotent."
    },
    "source": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "system": { "type": "string" },
        "provider": { "type": "string" },
        "eventId": { "type": "string" },
        "capturedAt": { "type": "string", "format": "date-time" }
      }
    },
    "profile": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "financialInstitutions": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "name": { "type": "string" },
              "routingNumber": { "type": "string" },
              "accountNumber": { "type": "string" },
              "accountType": { "type": "string", "enum": ["checking", "savings", "other"] }
            }
          }
        },
        "transactions": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["transactionDate", "amount", "direction"],
            "properties": {
              "transactionId": { "type": "string" },
              "transactionDate": { "type": "string", "format": "date" },
              "amount": {
                "type": "object",
                "additionalProperties": false,
                "required": ["value", "currency"],
                "properties": {
                  "value": { "type": "number" },
                  "currency": { "type": "string", "minLength": 3, "maxLength": 3 }
                }
              },
              "direction": { "type": "string", "enum": ["credit", "debit"] },
              "counterparty": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "name": { "type": "string" },
                  "account": { "type": "string" },
                  "routing": { "type": "string" }
                }
              }
            }
          }
        },
        "kyb": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "name": { "type": "string" },
            "ein": { "type": "string" },
            "physicalAddress": { "$ref": "#/$defs/addressWithSignals" },
            "phone": { "$ref": "#/$defs/phoneWithSignals" },
            "email": { "$ref": "#/$defs/emailWithSignals" },
            "website": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "url": { "type": "string" },
                "status": { "type": "string", "enum": ["active", "inactive", "unknown"] },
                "domainAgeDays": { "type": "integer", "minimum": 0 },
                "associatedSocialMedia": {
                  "type": "array",
                  "items": { "type": "string" }
                },
                "associatedPhysicalAddresses": {
                  "type": "array",
                  "items": { "$ref": "#/$defs/address" }
                }
              }
            }
          }
        },
        "kyc": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "name": { "type": "string" },
            "ssn": { "type": "string", "description": "Store/tokenize per policy; may be last4 only." },
            "physicalAddress": { "$ref": "#/$defs/address" },
            "phone": { "$ref": "#/$defs/phone" },
            "email": { "$ref": "#/$defs/email" }
          }
        },
        "location": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "ipAddress": { "type": "string" },
            "ipGeography": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "country": { "type": "string" },
                "region": { "type": "string" },
                "city": { "type": "string" },
                "postalCode": { "type": "string" },
                "latitude": { "type": "number" },
                "longitude": { "type": "number" }
              }
            },
            "deviceId": { "type": "string" },
            "ipDistanceToPhysicalAddressKm": { "type": "number", "minimum": 0 }
          }
        },
        "other": {
          "type": "object",
          "description": "Optional extension bucket for future profile signals.",
          "additionalProperties": true
        }
      }
    }
  },
  "$defs": {
    "address": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "line1": { "type": "string" },
        "line2": { "type": "string" },
        "city": { "type": "string" },
        "region": { "type": "string" },
        "postalCode": { "type": "string" },
        "country": { "type": "string" }
      }
    },
    "addressWithSignals": {
      "allOf": [
        { "$ref": "#/$defs/address" },
        {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "addressType": { "type": "string", "enum": ["residential", "business", "po_box", "unknown"] },
            "addressToNameMatch": { "type": "boolean" },
            "addressValidityLevel": { "type": "string", "enum": ["high", "medium", "low", "unknown"] }
          }
        }
      ]
    },
    "phone": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "e164": { "type": "string", "description": "E.164 formatted phone number." }
      }
    },
    "phoneWithSignals": {
      "allOf": [
        { "$ref": "#/$defs/phone" },
        {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "lineType": { "type": "string", "enum": ["mobile", "landline", "voip", "unknown"] },
            "carrier": { "type": "string" },
            "firstSeenAt": { "type": "string", "format": "date-time" },
            "lastSeenAt": { "type": "string", "format": "date-time" },
            "matches": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "phoneToNameMatch": { "type": "boolean" },
                "phoneToEmailMatch": { "type": "boolean" },
                "phoneToAddressMatch": { "type": "boolean" }
              }
            }
          }
        }
      ]
    },
    "email": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "address": { "type": "string" }
      }
    },
    "emailWithSignals": {
      "allOf": [
        { "$ref": "#/$defs/email" },
        {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "emailValid": { "type": "boolean" },
            "firstSeenAt": { "type": "string", "format": "date-time" },
            "lastSeenAt": { "type": "string", "format": "date-time" },
            "emailDomain": { "type": "string" },
            "emailToNameMatch": { "type": "boolean" },
            "emailDisposable": { "type": "boolean" },
            "emailRiskScore": { "type": "number", "minimum": 0, "maximum": 1 }
          }
        }
      ]
    }
  }
}

API docs live in the repo at identity-provider/API.md (paths above are the canonical integration surface).