Custom users
The custom users connection is a user provider that allows you to implement a set of webhook callbacks that UserHub uses to keep the UserHub user object in sync with your database.
There are two basic webhook actions you need to implement:
- List users: is used to import users and keep users up-to-date in bulk. This is called when first set up and periodically to ensure all users exist in UserHub. By default the most-recently-created users should be returned first.
- Get a user: is used to get an individual user during Portal sign-in and as part of the background sync process to ensure the user hasn't been deleted (if absent from list).
Setup webhook
First set up a webhook using one the supported SDKs.
Implement methods
Next you'll need to implement the list and get methods in your code.
userdao.ts
import { connectionsv1, WebhookUserNotFound } from "@userhub/sdk";
class User {
public id: number;
public firstName: string;
public lastName: string;
public email: string;
constructor({
id,
firstName,
lastName,
email,
}: {
id: number;
firstName?: string;
lastName?: string;
email?: string;
}) {
this.id = id;
this.firstName = firstName || "";
this.lastName = lastName || "";
this.email = email || "";
}
public toCustomUser(): connectionsv1.CustomUser {
return {
id: this.id.toString(),
displayName: (this.firstName + " " + this.lastName).trim(),
email: this.email,
emailVerified: true,
disabled: false,
};
}
}
export class UserDAO {
private users: User[];
constructor() {
this.users = [
new User({
id: 1,
firstName: "Donna",
lastName: "Beaufort",
email: "[email protected]",
}),
new User({
id: 2,
firstName: "Richard",
lastName: "Carroll",
email: "[email protected]",
}),
new User({
id: 3,
firstName: "Mike",
email: "[email protected]",
}),
];
// sort by newest first
this.users.sort((a: User, b: User): number => {
return a.id > b.id ? -1 : 1;
});
}
public async list(
input: connectionsv1.ListCustomUsersRequest,
): Promise<connectionsv1.ListCustomUsersResponse> {
const res: connectionsv1.ListCustomUsersResponse = { users: [] };
// page size is the max, you can return less
const pageSize = Math.min(input.pageSize, 2);
const start = Number(input.pageToken || "0");
if (isNaN(start)) {
console.log(`failed to parse page token: ${input.pageToken}`);
return res;
}
const end = Math.min(start + pageSize, this.users.length);
for (const user of this.users.slice(start, end)) {
res.users.push(user.toCustomUser());
}
if (this.users.length > end) {
res.nextPageToken = end.toString();
}
return res;
}
public async get(
input: connectionsv1.GetCustomUserRequest,
): Promise<connectionsv1.CustomUser> {
const id = Number(input.id);
for (const user of this.users) {
if (user.id === id) {
return user.toCustomUser();
}
}
throw new WebhookUserNotFound();
}
}
userdao.go
package main
import (
"cmp"
"context"
"log"
"slices"
"strconv"
"strings"
"github.com/userhubdev/go-sdk/connectionsv1"
"github.com/userhubdev/go-sdk/webhook"
)
type User struct {
ID int
FirstName string
LastName string
Email string
}
func (u *User) ToCustomUser() *connectionsv1.CustomUser {
return &connectionsv1.CustomUser{
Id: strconv.Itoa(u.ID),
DisplayName: strings.TrimSpace(u.FirstName + " " + u.LastName),
Email: u.Email,
EmailVerified: true,
Disabled: false,
}
}
type UserDAO struct {
users []*User
}
func NewUserDAO() *UserDAO {
userDAO := &UserDAO{
users: []*User{
{
ID: 1,
FirstName: "Donna",
LastName: "Beaufort",
Email: "[email protected]",
},
{
ID: 2,
FirstName: "Richard",
LastName: "Carroll",
Email: "[email protected]",
},
{
ID: 3,
FirstName: "Mike",
Email: "[email protected]",
},
},
}
// sort by newest first
slices.SortFunc(userDAO.users, func(a, b *User) int {
return cmp.Compare(b.ID, a.ID)
})
return userDAO
}
func (u *UserDAO) List(
ctx context.Context,
input *connectionsv1.ListCustomUsersRequest,
) (*connectionsv1.ListCustomUsersResponse, error) {
res := &connectionsv1.ListCustomUsersResponse{}
// page size is the max, you can return less
pageSize := min(int(input.PageSize), 2)
start, err := strconv.Atoi(cmp.Or(input.PageToken, "0"))
if err != nil {
log.Printf("failed to parse page token: %v", err)
return res, nil
}
end := min(start+pageSize, len(u.users))
for _, user := range u.users[start:end] {
res.Users = append(res.Users, user.ToCustomUser())
}
if len(res.Users) >= end {
res.NextPageToken = strconv.Itoa(end)
}
return res, nil
}
func (u *UserDAO) Get(
ctx context.Context,
input *connectionsv1.GetCustomUserRequest,
) (*connectionsv1.CustomUser, error) {
id, _ := strconv.Atoi(input.Id)
for _, user := range u.users {
if user.ID == id {
return user.ToCustomUser(), nil
}
}
return nil, webhook.UserNotFound
}
UserDAO.php
<?php
declare(strict_types=1);
use UserHub\ConnectionsV1\CustomUser;
use UserHub\ConnectionsV1\GetCustomUserRequest;
use UserHub\ConnectionsV1\ListCustomUsersRequest;
use UserHub\ConnectionsV1\ListCustomUsersResponse;
use UserHub\Webhook\WebhookUserNotFound;
class User
{
public int $id;
public string $firstName;
public string $lastName;
public string $email;
public function __construct(
int $id,
?string $firstName = null,
?string $lastName = null,
?string $email = null,
) {
$this->id = $id;
$this->firstName = $firstName ?? '';
$this->lastName = $lastName ?? '';
$this->email = $email ?? '';
}
public function toCustomUser(): CustomUser
{
return new CustomUser(
id: (string) $this->id,
displayName: trim("{$this->firstName} {$this->lastName}"),
email: $this->email,
emailVerified: true,
disabled: false,
);
}
}
class UserDAO
{
private array $users;
public function __construct()
{
$this->users = [
new User(
id: 1,
firstName: 'Donna',
lastName: 'Beaufort',
email: '[email protected]',
),
new User(
id: 2,
firstName: 'Richard',
lastName: 'Carroll',
email: '[email protected]',
),
new User(
id: 3,
firstName: 'Mike',
email: '[email protected]',
),
];
// sort by newest first
usort($this->users, static fn (User $a, User $b) => ($a->id > $b->id) ? -1 : 1);
}
public function list(ListCustomUsersRequest $req): ListCustomUsersResponse
{
$res = new ListCustomUsersResponse();
// page size is the max, you can return less
$pageSize = min($req->pageSize, 2);
if (!empty($req->pageToken) && !is_numeric($req->pageToken)) {
error_log("failed to parse page token: {$req->pageToken}");
return $res;
}
$start = (int) ($req->pageToken ?? '0');
$end = min($start + $pageSize, count($this->users));
foreach (array_slice($this->users, $start, $pageSize) as $user) {
$res->users[] = $user->toCustomUser();
}
if (count($this->users) > $end) {
$res->nextPageToken = (string) $end;
}
return $res;
}
public function get(GetCustomUserRequest $req): CustomUser
{
$id = (int) $req->id;
foreach ($this->users as $user) {
if ($user->id === $id) {
return $user->toCustomUser();
}
}
throw new WebhookUserNotFound();
}
}
userdao.py
import dataclasses
import logging
from userhub_sdk.connectionsv1 import (
CustomUser,
GetCustomUserRequest,
ListCustomUsersRequest,
ListCustomUsersResponse,
)
from userhub_sdk.webhook import WebhookUserNotFound
@dataclasses.dataclass
class User:
id: int
first_name: str = ""
last_name: str = ""
email: str = ""
def to_custom_user(self):
return CustomUser(
id=str(self.id),
display_name=f"{self.first_name} {self.last_name}".strip(),
email=self.email,
)
class UserDAO:
users: list[User]
def __init__(self):
self.users = [
User(
id=1,
first_name="Donna",
last_name="Beaufort",
email="[email protected]",
),
User(
id=2,
first_name="Richard",
last_name="Carroll",
email="[email protected]",
),
User(
id=3,
first_name="Mike",
email="[email protected]",
),
]
# sort by newest first
self.users.sort(key=lambda x: x.id, reverse=True)
def list(self, req: ListCustomUsersRequest) -> ListCustomUsersResponse:
res = ListCustomUsersResponse()
# page size is the max, you can return less
page_size = min(req.page_size, 2)
if req.page_token and not req.page_token.isdigit():
logging.error("failed to parse page token: %s", req.page_token)
return res
start = int(req.page_token or "0")
end = min(start + page_size, len(self.users))
for user in self.users[start:end]:
res.users.append(user.to_custom_user())
if len(self.users) > end:
res.next_page_token = str(end)
return res
def get(self, req: GetCustomUserRequest) -> CustomUser:
id = int(req.id) if req.id.isdigit() else None
for user in self.users:
if user.id == id:
return user.to_custom_user()
raise WebhookUserNotFound()
Optional: implement tests
Ideally you'll implement tests for your implementation.
userdao.test.ts
import assert from "node:assert";
import test from "node:test";
import { WebhookUserNotFound } from "@userhub/sdk";
import { UserDAO } from "./userdao";
test("UserDAO.list", async () => {
const userDAO = new UserDAO();
let res = await userDAO.list({ pageSize: 100 });
assert.equal(res.users.length, 2);
assert.equal(res.users[0].id, "3");
assert.equal(res.users[1].id, "2");
assert.equal(res.nextPageToken, "2");
res = await userDAO.list({ pageSize: 100, pageToken: res.nextPageToken });
assert.equal(res.users.length, 1);
assert.equal(res.users[0].id, "1");
assert.equal(res.nextPageToken, undefined);
});
test("UserDAO.get", async () => {
const userDAO = new UserDAO();
let user = await userDAO.get({ id: "1" });
assert.equal(user.id, "1");
assert.equal(user.displayName, "Donna Beaufort");
assert.equal(user.email, "[email protected]");
await assert.rejects(userDAO.get({ id: "20" }), WebhookUserNotFound);
});
userdao_test.go
package main
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/userhubdev/go-sdk/connectionsv1"
"github.com/userhubdev/go-sdk/webhook"
)
func TestUserDAO_List(t *testing.T) {
ctx := context.Background()
userDAO := NewUserDAO()
res, err := userDAO.List(ctx, &connectionsv1.ListCustomUsersRequest{
PageSize: 100,
})
require.NoError(t, err)
require.Len(t, res.Users, 2)
require.Equal(t, "3", res.Users[0].Id)
require.Equal(t, "2", res.Users[1].Id)
require.Equal(t, "2", res.NextPageToken)
res, err = userDAO.List(ctx, &connectionsv1.ListCustomUsersRequest{
PageSize: 100,
PageToken: res.NextPageToken,
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, "1", res.Users[0].Id)
require.Empty(t, res.NextPageToken)
}
func TestUserDAO_Get(t *testing.T) {
ctx := context.Background()
userDAO := NewUserDAO()
user, err := userDAO.Get(ctx, &connectionsv1.GetCustomUserRequest{
Id: "1",
})
require.NoError(t, err)
require.Equal(t, "1", user.Id)
require.Equal(t, "Donna Beaufort", user.DisplayName)
require.Equal(t, "[email protected]", user.Email)
_, err = userDAO.Get(ctx, &connectionsv1.GetCustomUserRequest{
Id: "20",
})
require.ErrorIs(t, err, webhook.UserNotFound)
}
UserDAOTest.php
<?php
declare(strict_types=1);
require_once 'UserDAO.php';
use PHPUnit\Framework\TestCase;
use UserHub\ConnectionsV1\GetCustomUserRequest;
use UserHub\ConnectionsV1\ListCustomUsersRequest;
use UserHub\Webhook\WebhookUserNotFound;
final class UserDAOTest extends TestCase
{
public function testList(): void
{
$userDAO = new UserDAO();
$res = $userDAO->list(new ListCustomUsersRequest(pageSize: 100));
$this->assertCount(2, $res->users);
$this->assertEquals('3', $res->users[0]->id);
$this->assertEquals('2', $res->users[1]->id);
$this->assertEquals('2', $res->nextPageToken);
$res = $userDAO->list(new ListCustomUsersRequest(pageSize: 100, pageToken: $res->nextPageToken));
$this->assertCount(1, $res->users);
$this->assertEquals('1', $res->users[0]->id);
$this->assertEmpty($res->nextPageToken);
}
public function testGet(): void
{
$userDAO = new UserDAO();
$user = $userDAO->get(new GetCustomUserRequest(id: '1'));
$this->assertEquals('1', $user->id);
$this->assertEquals('Donna Beaufort', $user->displayName);
$this->assertEquals('[email protected]', $user->email);
$this->expectException(WebhookUserNotFound::class);
$userDAO->get(new GetCustomUserRequest(id: '20'));
}
}
userdao_test.py
import unittest
from userhub_sdk.connectionsv1 import GetCustomUserRequest, ListCustomUsersRequest
from userhub_sdk.webhook import WebhookUserNotFound
from userdao import UserDAO
class TestUserDAO(unittest.TestCase):
def test_list(self):
user_dao = UserDAO()
res = user_dao.list(ListCustomUsersRequest(page_size=100))
self.assertEqual(len(res.users), 2)
self.assertEqual(res.users[0].id, "3")
self.assertEqual(res.users[1].id, "2")
self.assertEqual(res.next_page_token, "2")
res = user_dao.list(
ListCustomUsersRequest(page_size=100, page_token=res.next_page_token)
)
self.assertEqual(len(res.users), 1)
self.assertEqual(res.users[0].id, "1")
self.assertEqual(res.next_page_token, "")
def test_get(self):
user_dao = UserDAO()
user = user_dao.get(GetCustomUserRequest(id="1"))
self.assertEqual(user.id, "1")
self.assertEqual(user.display_name, "Donna Beaufort")
self.assertEqual(user.email, "[email protected]")
self.assertRaises(
WebhookUserNotFound, user_dao.get, GetCustomUserRequest(id="20")
)
Register actions
Next you'll to update the webhook handler to call your new methods.
main.ts
import { UserDAO } from "./userdao";
// ...
const wh = new Webhook(signingSecret);
const userDAO = new UserDAO();
wh.onListUsers(userDAO.list.bind(userDAO));
wh.onGetUser(userDAO.get.bind(userDAO));
// ...
main.go
// ...
wh := webhook.New(signingSecret)
userDAO := NewUserDAO()
wh.OnListUsers(userDAO.List)
wh.OnGetUser(userDAO.Get)
// ...
main.php
// ...
require_once 'UserDAO.php';
// ...
$wh = new Webhook($signingSecret);
$userDAO = new UserDAO();
$wh->onListUsers($userDAO->list(...));
$wh->onGetUser($userDAO->get(...));
// ...
main.py
from userdao import UserDAO
# ...
wh = Webhook(signing_secret)
user_dao = UserDAO()
wh.on_list_users(user_dao.list)
wh.on_get_user(user_dao.get)
# ...
Create connection
The final step is to create the custom users connection.
- Ensure the webhook is running and publicly routable
- Go to the Admin console and click Connections via the Developers dropdown or Tenant settings
- Click the Setup button on Custom users
- Select the desired webhook and click Save
- If the connection is marked as Active, you should be able to navigate to Users and see the imported users