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:

ModeHow you get itWhat it can do
As your backendoauth.profile()Any user in your project. The scopes come from What can this backend do? in the Admin Console.
As the signed-in userprofiles.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:

  1. Set Client authentication to Enhanced (private_key_jwt) and download the private key. See Confidential Clients.
  2. Turn on Profile access (hx_profile). Only a project admin can change it.
  3. Under What can this backend do?, pick View only or View, edit & delete.
  4. To act for signed-in users, turn on Let users manage their own profile.
  5. 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.

OptionDefaultDescription
baseUrl{issuer}/profileProfile API base URL. Must be https (plain http only on localhost).
scopesall the client's scopesAsk for fewer scopes, e.g. ['users:read']. Omit it to use all of them; an empty list is rejected when you build the client.
audiencehx-profileToken-exchange audience for forUser. Change it only if Hawcx support gives you a different value.
timeout10000Timeout in ms. It bounds each Profile API request and each access-token request.
maxRetries2Retries 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);
}
MethodReturnsScope
getUser(userId)Profileusers:read
listUsers({ email?, status?, cursor?, limit? })Page<Profile>users:read
iterUsers({ email?, status?, limit? })AsyncGenerator<Profile>users:read
createUser(input)Profileusers:write
updateUser(userId, patch)Profileusers:write
replaceUser(userId, input)Profileusers:write
deleteUser(userId)voidusers:delete
getConsents(userId, { cursor?, limit? })ConsentsPageconsent:read
setConsent(userId, purpose, input)Consentconsent: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' });
MethodReturns
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.

TypeFields
Profileuser_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
ConsentInputstatus ('granted' or 'withdrawn'), and optional lawful_basis, notice_version, source
Consentconsent_id, purpose, status, lawful_basis, notice_version, source, created_at
AuditEventevent_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;
  }
}
PropertyDescription
statusHTTP status; 0 if no response arrived (network error or timeout).
title, detail, type, instanceThe API's error fields.
requestIdInclude this when you contact support.
missingOn 403: the scopes the client is missing. null when the API leaves it out.
fieldsOn 422, when the API names them: the fields that were rejected. null when the API leaves it out.
retryAfterSeconds to wait, when the API sent one.
rawBodyThe 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. deleteUser is 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 within timeout. 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, PUT and DELETE are retried up to maxRetries times on network errors and on 408, 429, 502, 503 and 504. The client waits as long as the Retry-After header asks (up to 30 seconds), otherwise a short random backoff. It honours only the delta-seconds form of Retry-After; it ignores an HTTP date, so it uses the short backoff and retryAfter is null.
  • POST and PATCH are 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 forUser and 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 maxRetries and use the short backoff only.
  • A retried deleteUser that 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

Last updated on