Node.js Profile Service
Read and manage user profiles, consents and the activity log from your Node.js backend with ProfileClient
ProfileClient calls the Hawcx Profile API: the profile, consent records and
activity log Hawcx keeps for each user. It runs on your backend only, and it
works in two modes:
| Mode | How you get it | What it can do |
|---|---|---|
| As your backend | oauth.profile() | Any user in your project. The scopes come from What can this backend do? in the Admin Console. |
| As the signed-in user | profiles.forUser(idToken) | Only that user's own profile, consents and activity (/v1/me). |
Available in @hawcx/oauth-client 6.2.0 and later.
Before you start
In the Admin Console, open your project → Settings → OAuth tab, and in OIDC Clients edit the client your backend signs in with:
- Set Client authentication to Enhanced (private_key_jwt) and download the private key. See Confidential Clients.
- Turn on Profile access (hx_profile). Only a project admin can change it.
- Under What can this backend do?, pick View only or View, edit & delete.
- To act for signed-in users, turn on Let users manage their own profile.
- Save the client.
View only grants users:read, consent:read and audit:read.
View, edit & delete adds users:write, users:delete and consent:write.
Let users manage their own profile grants profile:self. When you turn
on profile access, the console grants View only by default.
Use the same client for sign-in
forUser only accepts an ID token that was issued to this same client. If
users sign in through a different client, the exchange fails with
invalid_grant.
Set up
Create the client once at startup and reuse it. It caches its access token and
refreshes it before it expires. If a refresh fails while the current token is
still valid (for any reason), the client keeps using it, so a short outage at
the token endpoint does not fail your calls. It then waits 30 seconds, or until
the token expires if that is sooner, before it tries to refresh early again. A refresh that starts before the token expires runs in the background, and the caller gets the current token straight away. Tokens that forUser gets are not refreshed early: they are
refreshed only when they expire.
import { HawcxOAuth, ClientAssertionSigner } from '@hawcx/oauth-client';
const oauth = (await HawcxOAuth.fromIssuer({
issuer: process.env.HAWCX_BASE_URL!,
configId: process.env.HAWCX_CONFIG_ID!,
clientId: process.env.HAWCX_CLIENT_ID!,
})).withClientAssertion(
ClientAssertionSigner.ed25519FromJwk(process.env.HAWCX_OAUTH_PRIVATE_KEY_JWK!),
);
const profiles = oauth.profile();oauth.profile(options) and new ProfileClient(oauth, options) are the same.
Both throw if oauth was not built with fromIssuer and withClientAssertion.
| Option | Default | Description |
|---|---|---|
baseUrl | {issuer}/profile | Profile API base URL. Must be https (plain http only on localhost). |
scopes | all the client's scopes | Ask for fewer scopes, e.g. ['users:read']. Omit it to use all of them; an empty list is rejected when you build the client. |
audience | hx-profile | Token-exchange audience for forUser. Change it only if Hawcx support gives you a different value. |
timeout | 10000 | Timeout in ms. It bounds each Profile API request and each access-token request. |
maxRetries | 2 | Retries for GET, PUT and DELETE, and for failures getting the access token. See Retries. |
As your backend
const user = await profiles.getUser('user-123');
// null clears a field (not `status`, which can't be cleared); a key you leave out is unchanged.
await profiles.updateUser('user-123', { display_name: 'Ada', locale: null });
await profiles.setConsent('user-123', 'marketing', { status: 'granted' });
for await (const p of profiles.iterUsers({ status: 'active' })) {
console.log(p.user_id, p.email);
}| Method | Returns | Scope |
|---|---|---|
getUser(userId) | Profile | users:read |
listUsers({ email?, status?, cursor?, limit? }) | Page<Profile> | users:read |
iterUsers({ email?, status?, limit? }) | AsyncGenerator<Profile> | users:read |
createUser(input) | Profile | users:write |
updateUser(userId, patch) | Profile | users:write |
replaceUser(userId, input) | Profile | users:write |
deleteUser(userId) | void | users:delete |
getConsents(userId, { cursor?, limit? }) | ConsentsPage | consent:read |
setConsent(userId, purpose, input) | Consent | consent:write |
getAudit(userId, { cursor?, limit? }) | Page<AuditEvent> | audit:read |
iterAudit(userId, { limit? }) | AsyncGenerator<AuditEvent> | audit:read |
forUser(idToken) | UserProfileClient | — |
createUser adds a profile for a user who already exists in Hawcx: pass their
user_id with given_name and family_name. It fails with 404 if there is no
such user and 409 if they already have a profile.
replaceUser replaces the whole profile: it needs the given and family name,
and clears every other optional field you leave out (status is kept). Use
the update method to change only some fields.
As the signed-in user
Pass the ID token you just received at sign-in, and make the calls straight away.
Only right after sign-in
An ID token is valid for 60 seconds by default, and the token that
forUser gets lasts no longer than the ID token. Don't store the ID token
for later. To read or change a user's profile later (for example, on an
account page), use the backend methods with their user_id (the sub claim).
Once the ID token expires, calls fail with invalid_grant.
const { idToken } = await oauth.exchangeCode(code, codeVerifier);
const me = profiles.forUser(idToken);
const mine = await me.getMe();
await me.updateMe({ given_name: 'Ada' });
await me.setMyConsent('marketing', { status: 'withdrawn' });| Method | Returns |
|---|---|
getMe() | Profile |
updateMe(patch) | Profile |
getMyConsents({ cursor?, limit? }) | ConsentsPage |
setMyConsent(purpose, input) | Consent |
getMyAudit({ cursor?, limit? }) | Page<AuditEvent> |
iterMyAudit({ limit? }) | AsyncGenerator<AuditEvent> |
updateMe takes only the fields a user may change about themselves: given_name, family_name, display_name, date_of_birth, address, locale and timezone. The API answers any other field (for example status) with 422.
Data shapes
Field names are snake_case, exactly as the API sends them.
| Type | Fields |
|---|---|
Profile | user_id, tenant_id, email, email_verified, phone, phone_verified, given_name, family_name, display_name, date_of_birth, address, locale, timezone, marketing_opt_in, custom_attributes, status, created_at, updated_at |
ConsentInput | status ('granted' or 'withdrawn'), and optional lawful_basis, notice_version, source |
Consent | consent_id, purpose, status, lawful_basis, notice_version, source, created_at |
AuditEvent | event_id, actor, action, resource, before_hash, after_hash, detail, created_at |
Lists return { items, nextCursor }. Pass nextCursor back as cursor to get
the next page; it is null on the last page. An empty string also ends paging. limit is 1–200 (default 50).
Consents return { current, history, nextCursor }, where current maps each
purpose to its latest consent. The iter* methods follow every page for you.
Errors
An ID or consent purpose that is empty, ., .., or contains /, \ or a
control character is rejected before any request with HawcxError.
A failed call throws ProfileApiError. Getting the access token can also
fail with TokenExchangeError (for example, invalid_scope when the client
has no profile access).
import { ProfileApiError } from '@hawcx/oauth-client';
try {
await profiles.getUser('user-123');
} catch (err) {
if (err instanceof ProfileApiError && err.status === 404) {
// no such user
} else {
throw err;
}
}| Property | Description |
|---|---|
status | HTTP status; 0 if no response arrived (network error or timeout). |
title, detail, type, instance | The API's error fields. |
requestId | Include this when you contact support. |
missing | On 403: the scopes the client is missing. null when the API leaves it out. |
fields | On 422, when the API names them: the fields that were rejected. null when the API leaves it out. |
retryAfter | Seconds to wait, when the API sent one. |
rawBody | The response body as text (up to 4,096 characters), for example a gateway's HTML error page. Never part of the message. |
A call that succeeds on the wire but returns an unusable body also throws ProfileApiError, with that 2xx status:
Invalid JSON response: the body isn't JSON.Unexpected response body: the body is JSON but not the expected object, or the API answered 204 to a call that returns a value.deleteUseris the exception: any 2xx with an empty body, a whitespace-only body or a body that is not a JSON object counts as success.Response body read failed: the response started but its body never arrived in full: the connection dropped, or the body did not finish withintimeout. The request reached the API, so a create or update may have happened; check before you try again.
On a 2xx, the raw body is kept only for a markup page such as HTML or XML (after any whitespace or BOM, it starts with <); otherwise it is null, because a damaged response may still hold a user's profile.
Retries
GET,PUTandDELETEare retried up tomaxRetriestimes on network errors and on 408, 429, 502, 503 and 504. The client waits as long as theRetry-Afterheader asks (up to 30 seconds), otherwise a short random backoff. It honours only the delta-seconds form ofRetry-After; it ignores an HTTP date, so it uses the short backoff andretryAfterisnull.POSTandPATCHare never retried once the Profile API has accepted the token, so a create or update never runs twice.- On 401, for every method, the client gets a new token and tries once more. The API rejects a request with an invalid token before doing anything, so this can't repeat a change. If you used
forUserand getting the new token then fails because the ID token has expired, the call throws the original 401 error (ProfileApiError, status 401), not the token error. - If getting the access token fails with a network error or 408, 429, 502, 503 or 504, the call is retried whatever its method, because nothing was sent to the Profile API yet. These retries count toward
maxRetriesand use the short backoff only. - A retried
deleteUserthat ends in 404 succeeds if an earlier attempt may already have deleted the user: that attempt was sent but got no response (a failure to connect doesn't count), had its 2xx body cut off mid-read, or got 504. Otherwise the 404 is thrown. - Redirects are never followed, so the token is never sent to another host.
Next steps
- Node.js API Reference — the rest of
@hawcx/oauth-client. - Confidential Clients (private_key_jwt) — the key this client signs with.
Last updated on